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
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand All @@ -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

Expand All @@ -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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
28 changes: 15 additions & 13 deletions references/lint-format-typing-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions references/pyproject-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 16 additions & 6 deletions scripts/sync-payload.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down
42 changes: 41 additions & 1 deletion scripts/test-sync-payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
69 changes: 69 additions & 0 deletions scripts/test-validate-ci.py
Original file line number Diff line number Diff line change
@@ -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())
Loading