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
Empty file modified .claude/hooks/devcontainer-policy-blocker.sh
100644 → 100755
Empty file.
66 changes: 66 additions & 0 deletions .github/workflows/template-integration.yml
Original file line number Diff line number Diff line change
@@ -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 }}"
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
249 changes: 249 additions & 0 deletions scripts/test_template_integration.sh
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +125 to +136
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Scan all text files for unreplaced placeholders.

This allowlist skips extensionless text files, so files like .devcontainer/Dockerfile can retain {{python_version}} and Step 3 still passes. That defeats the main “template fully applied” assertion.

Suggested fix
-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' \
+PLACEHOLDER_HITS=$(grep -rIE "$PLACEHOLDER_PATTERN" \
     --exclude='setup_project.py' \
     --exclude='test_setup_project.py' \
     --exclude='test_template_integration.sh' \
     . 2>/dev/null || true)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# 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)
# 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 -rIE "$PLACEHOLDER_PATTERN" \
--exclude='setup_project.py' \
--exclude='test_setup_project.py' \
--exclude='test_template_integration.sh' \
. 2>/dev/null || true)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/test_template_integration.sh` around lines 115 - 126, The grep
currently only searches a fixed set of file extensions so extensionless text
files (e.g., .devcontainer/Dockerfile) can escape detection; update the
PLACEHOLDER_HITS search (the command that uses PLACEHOLDER_PATTERN and
--include/--exclude flags) to also scan extensionless files by either adding a
broad --include='*' (and keep relevant --exclude entries) or explicitly
including common extensionless filenames like Dockerfile and CI config names so
all text files are checked for TEMPLATE placeholders.


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 ==="
6 changes: 5 additions & 1 deletion tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading