diff --git a/.claude/hooks/devcontainer-policy-blocker.sh b/.claude/hooks/devcontainer-policy-blocker.sh old mode 100644 new mode 100755 diff --git a/.github/workflows/template-integration.yml b/.github/workflows/template-integration.yml new file mode 100644 index 0000000..4a4029e --- /dev/null +++ b/.github/workflows/template-integration.yml @@ -0,0 +1,66 @@ +name: Template Integration Tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install test dependencies + run: pip install pytest + - name: Run unit tests + run: python -m pytest tests/ -v + + integration-test: + name: Integration (${{ matrix.config-name }}) + runs-on: ubuntu-latest + needs: unit-tests + strategy: + fail-fast: false + matrix: + include: + - config-name: mono-default + project-type: mono + packages: "core,server" + services: none + - config-name: mono-renamed + project-type: mono + packages: "engine,daemon" + services: none + - config-name: mono-extra-pkgs + project-type: mono + packages: "engine,lib:utils,daemon,worker" + services: none + - config-name: single-package + project-type: single + packages: "core,server" # ignored by setup_project.py in single mode + services: none + - config-name: mono-postgres + project-type: mono + packages: "core,server" + services: postgres + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - uses: astral-sh/setup-uv@v5 + with: + version: ">=0.5.0" + - name: Run template integration test + run: | + bash scripts/test_template_integration.sh \ + --source-dir "$GITHUB_WORKSPACE" \ + --work-dir "/tmp/test-${{ matrix.config-name }}" \ + --project-type "${{ matrix.project-type }}" \ + --packages "${{ matrix.packages }}" \ + --services "${{ matrix.services }}" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 14b3128..fe25f96 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Template integration CI pipeline (`template-integration.yml`) tests `setup_project.py` across 5 configurations (mono-default, mono-renamed, mono-extra-pkgs, single-package, mono-postgres) -- verifies each produces a valid project that installs, lints, type-checks, and passes tests +- Reusable `scripts/test_template_integration.sh` for local template validation with the same 9-step verification as CI - Workflow skill `/sync` checks workspace readiness before starting work (git fetch, status, branch info, warnings) - Workflow skill `/design` crystallizes brainstorming into structured plans with conflict detection against DECISIONS.md - Workflow skill `/done` auto-detects scope (Q/S/P) and runs the full validate-ship-document pipeline, including the former `/ship` checklist diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5b3c5fd..37fd667 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -113,6 +113,19 @@ When a decision is superseded or obsolete, delete it (git history preserves the - `/sync` and `/done` have `disable-model-invocation: true` (side effects: git fetch, git commit/push, PR creation); `/design` is intentionally model-invocable so Claude can suggest it during brainstorming - QSP paths (Q/S/P) and their step descriptions preserved in DEVELOPMENT_PROCESS.md -- skills orchestrate the paths, they don't replace them +## 2026-03-10: Template Integration CI Pipeline + +**Request**: Create a CI pipeline that applies the template in various settings to catch template bugs before merge. + +**Decisions**: +- New workflow `template-integration.yml` (not extending `tests.yml`) -- `tests.yml` has `{{base_branch}}` in its trigger and never fires on the raw template repo +- GitHub Actions matrix (5 configs) + reusable shell script (`scripts/test_template_integration.sh`) -- matrix defines WHAT to test, script defines HOW to verify; script also runnable locally +- Copy template to temp dir before applying -- `setup_project.py` modifies in-place, would destroy the checkout +- 5 matrix configs cover all major code paths: default monorepo, package renaming, additional packages, single-package conversion, Docker Compose services +- Unit tests gate job runs first -- fail fast if setup_project.py functions are broken before spending matrix resources +- Placeholder check uses named pattern matching (`{{project_name}}` etc.) not generic `{{` -- avoids false positives from GitHub Actions `${{ }}` expressions +- `test_setup_project.py` excluded from integration pytest runs -- tests setup script internals (already covered by unit-tests job), fails on single-package layout + ## 2026-03-04: Devcontainer Permission Tiers **Request**: Expand Claude Code permissions for devcontainer usage, taking advantage of container isolation (firewall, non-root user, hooks) to reduce unnecessary permission prompts. diff --git a/scripts/test_template_integration.sh b/scripts/test_template_integration.sh new file mode 100644 index 0000000..203c60e --- /dev/null +++ b/scripts/test_template_integration.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# Template integration test: apply setup_project.py with a given configuration +# and verify the resulting project builds, lints, type-checks, and passes tests. +# +# Usage: +# scripts/test_template_integration.sh \ +# --source-dir /path/to/template \ +# --work-dir /tmp/test-project \ +# --project-type mono \ +# --packages "core,server" \ +# --services none + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- +SOURCE_DIR="" +WORK_DIR="" +PROJECT_TYPE="mono" +PACKAGES="core,server" +SERVICES="none" + +while [[ $# -gt 0 ]]; do + case "$1" in + --source-dir) SOURCE_DIR="$2"; shift 2 ;; + --work-dir) WORK_DIR="$2"; shift 2 ;; + --project-type) PROJECT_TYPE="$2"; shift 2 ;; + --packages) PACKAGES="$2"; shift 2 ;; + --services) SERVICES="$2"; shift 2 ;; + --help|-h) + echo "Usage: $0 --source-dir DIR --work-dir DIR [--project-type mono|single] [--packages LIST] [--services none|postgres|postgres-redis|custom]" + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +if [[ -z "$SOURCE_DIR" || -z "$WORK_DIR" ]]; then + echo "ERROR: --source-dir and --work-dir are required" >&2 + exit 1 +fi + +# Guard against rm -rf on dangerous paths (/, $HOME, source dir). +RESOLVED_WORK=$(cd "$SOURCE_DIR" 2>/dev/null && pwd) # resolve SOURCE_DIR for comparison +RESOLVED_SRC="$RESOLVED_WORK" +case "$WORK_DIR" in + /|"$HOME"|"$RESOLVED_SRC") + echo "ERROR: --work-dir '$WORK_DIR' is unsafe (matches /, \$HOME, or --source-dir)" >&2 + exit 1 + ;; +esac + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +step_pass() { echo " [PASS] $1"; } +step_fail() { echo " [FAIL] $1" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# Banner +# --------------------------------------------------------------------------- +echo "=== Template Integration Test ===" +echo " type=$PROJECT_TYPE packages=$PACKAGES services=$SERVICES" +echo " source: $SOURCE_DIR" +echo " work: $WORK_DIR" +echo "" + +# --------------------------------------------------------------------------- +# Step 1: Copy template to work directory +# --------------------------------------------------------------------------- +echo "Step 1: Copy template to work directory" +rm -rf "$WORK_DIR" +mkdir -p "$WORK_DIR" + +# Use rsync if available, otherwise fall back to cp + cleanup +if command -v rsync > /dev/null 2>&1; then + rsync -a \ + --exclude='.git' \ + --exclude='.venv' \ + --exclude='__pycache__' \ + --exclude='.coverage' \ + --exclude='.ruff_cache' \ + --exclude='.pytest_cache' \ + --exclude='node_modules' \ + "$SOURCE_DIR/" "$WORK_DIR/" +else + cp -a "$SOURCE_DIR/." "$WORK_DIR/" + rm -rf "$WORK_DIR/.git" "$WORK_DIR/.venv" "$WORK_DIR/node_modules" + find "$WORK_DIR" -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true + find "$WORK_DIR" -type d -name '.ruff_cache' -exec rm -rf {} + 2>/dev/null || true + find "$WORK_DIR" -type d -name '.pytest_cache' -exec rm -rf {} + 2>/dev/null || true +fi +step_pass "Copied template" + +# --------------------------------------------------------------------------- +# Step 2: Apply template +# --------------------------------------------------------------------------- +echo "Step 2: Apply template (setup_project.py)" +python "$WORK_DIR/setup_project.py" \ + --name test-project \ + --namespace test_project \ + --description "CI integration test" \ + --author "CI Bot" \ + --email "ci@test.com" \ + --python-version 3.11 \ + --base-branch main \ + --type "$PROJECT_TYPE" \ + --packages "$PACKAGES" \ + --services "$SERVICES" \ + --keep-setup \ +|| step_fail "setup_project.py exited with non-zero status" +step_pass "Template applied" + +# --------------------------------------------------------------------------- +# Step 3: Verify no remaining placeholders +# --------------------------------------------------------------------------- +echo "Step 3: Check for remaining template placeholders" +# All subsequent steps run inside $WORK_DIR (uv sync/run need the project root as cwd). +cd "$WORK_DIR" + +# Search for actual template placeholders (not GitHub Actions ${{ }} expressions). +# The pattern matches {{word}} but NOT ${{word}} (GHA syntax). +# Excludes setup_project.py (defines them) and test files (reference them in fixtures). +PLACEHOLDER_PATTERN='\{\{(project_name|namespace|description|author_name|author_email|python_version|base_branch|year)\}\}' +PLACEHOLDER_HITS=$(grep -rE "$PLACEHOLDER_PATTERN" \ + --include='*.py' --include='*.toml' --include='*.yml' --include='*.yaml' \ + --include='*.md' --include='*.json' --include='*.cfg' --include='*.ini' \ + --include='*.txt' --include='*.sh' \ + --exclude='setup_project.py' \ + --exclude='test_setup_project.py' \ + --exclude='test_template_integration.sh' \ + . 2>/dev/null || true) + +if [[ -n "$PLACEHOLDER_HITS" ]]; then + echo "$PLACEHOLDER_HITS" + step_fail "Found remaining template placeholders" +fi +step_pass "No remaining placeholders" + +# --------------------------------------------------------------------------- +# Step 4: Verify directory structure +# --------------------------------------------------------------------------- +echo "Step 4: Verify directory structure" +if [[ "$PROJECT_TYPE" == "single" ]]; then + [[ -d "src/test_project" ]] || step_fail "src/test_project/ missing" + [[ ! -d "libs" ]] || step_fail "libs/ should not exist in single-package mode" + [[ ! -d "apps" ]] || step_fail "apps/ should not exist in single-package mode" + step_pass "Single-package layout correct" +else + [[ -d "libs" ]] || step_fail "libs/ missing" + [[ -d "apps" ]] || step_fail "apps/ missing" + # Verify each requested package exists in the expected directory. + # setup_project.py treats the first unlabeled package as a lib, subsequent + # unlabeled ones as apps. lib: and app: prefixes override this. + FIRST_UNLABELED=true + IFS=',' read -ra PKG_LIST <<< "$PACKAGES" + for pkg in "${PKG_LIST[@]}"; do + pkg=$(echo "$pkg" | xargs) # trim whitespace + if [[ "$pkg" == lib:* ]]; then + pkg_name="${pkg#lib:}" + [[ -d "libs/$pkg_name" ]] || step_fail "libs/$pkg_name/ missing (from lib:$pkg_name)" + elif [[ "$pkg" == app:* ]]; then + pkg_name="${pkg#app:}" + [[ -d "apps/$pkg_name" ]] || step_fail "apps/$pkg_name/ missing (from app:$pkg_name)" + elif $FIRST_UNLABELED; then + FIRST_UNLABELED=false + [[ -d "libs/$pkg" ]] || step_fail "libs/$pkg/ missing (first package defaults to lib)" + else + [[ -d "apps/$pkg" ]] || step_fail "apps/$pkg/ missing (subsequent packages default to app)" + fi + done + step_pass "Monorepo layout correct (all packages present)" +fi + +# --------------------------------------------------------------------------- +# Step 5: Install dependencies +# --------------------------------------------------------------------------- +echo "Step 5: Install dependencies (uv sync)" +if [[ "$PROJECT_TYPE" == "single" ]]; then + uv sync --group dev || step_fail "uv sync failed" +else + uv sync --all-packages --group dev || step_fail "uv sync failed" +fi +step_pass "Dependencies installed" + +# --------------------------------------------------------------------------- +# Step 6: Lint +# --------------------------------------------------------------------------- +echo "Step 6: Lint (ruff)" +uv run ruff check . || step_fail "ruff check failed" +uv run ruff format --check . || step_fail "ruff format check failed" +step_pass "Lint passed" + +# --------------------------------------------------------------------------- +# Step 7: Type check +# --------------------------------------------------------------------------- +echo "Step 7: Type check (pyright)" +uv run pyright || step_fail "pyright failed" +step_pass "Type check passed" + +# --------------------------------------------------------------------------- +# Step 8: Run tests (pytest) +# --------------------------------------------------------------------------- +echo "Step 8: Run tests (pytest)" +# Run package tests only (not root tests/ which contains template meta-tests +# already covered by the unit-tests CI job). +if [[ "$PROJECT_TYPE" == "single" ]]; then + uv run pytest tests/ -v --tb=short --ignore=tests/test_setup_project.py \ + || step_fail "pytest failed" +else + uv run pytest libs/ apps/ tests/ -v --tb=short --ignore=tests/test_setup_project.py \ + || step_fail "pytest failed" +fi +step_pass "Tests passed" + +# --------------------------------------------------------------------------- +# Step 9: Verify services (if applicable) +# --------------------------------------------------------------------------- +if [[ "$SERVICES" != "none" ]]; then + echo "Step 9: Verify docker-compose.yml" + COMPOSE_FILE=".devcontainer/docker-compose.yml" + [[ -f "$COMPOSE_FILE" ]] || step_fail "$COMPOSE_FILE missing" + grep -q 'services:' "$COMPOSE_FILE" \ + || step_fail "$COMPOSE_FILE missing 'services:' key" + # Verify the requested service is actually defined (db for postgres profiles). + case "$SERVICES" in + postgres) + grep -q ' db:' "$COMPOSE_FILE" \ + || step_fail "$COMPOSE_FILE missing 'db' service for --services postgres" + ;; + postgres-redis) + grep -q ' db:' "$COMPOSE_FILE" \ + || step_fail "$COMPOSE_FILE missing 'db' service for --services postgres-redis" + grep -q ' redis:' "$COMPOSE_FILE" \ + || step_fail "$COMPOSE_FILE missing 'redis' service for --services postgres-redis" + ;; + esac + step_pass "docker-compose.yml present with expected services" +fi + +# --------------------------------------------------------------------------- +# Done +# --------------------------------------------------------------------------- +echo "" +echo "=== All checks passed ===" diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 2586364..544c486 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -18,7 +18,11 @@ "test-on-change.sh", ] -ALL_HOOKS = SECURITY_HOOKS + PRODUCTIVITY_HOOKS +DEVCONTAINER_HOOKS = [ + "devcontainer-policy-blocker.sh", +] + +ALL_HOOKS = SECURITY_HOOKS + PRODUCTIVITY_HOOKS + DEVCONTAINER_HOOKS class TestHookExistence: