diff --git a/.github/scripts/assert-guardrails-bite.sh b/.github/scripts/assert-guardrails-bite.sh new file mode 100755 index 0000000..9d8e866 --- /dev/null +++ b/.github/scripts/assert-guardrails-bite.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env sh +# Assert every guardrail FAILS on a real violation — not just that a clean +# project passes. A guardrail that stops biting is worse than no guardrail. +# +# Usage: assert-guardrails-bite.sh +# The dir must be a rendered project (a git repo, uv-synced). Each check injects +# a genuine violation, runs the specific `just` recipe, asserts it fails, and +# reverts. Exits non-zero if any guardrail failed to bite. +set -u + +DEST=${1:?usage: assert-guardrails-bite.sh } +cd "$DEST" || exit 1 +PKG=$(find src -mindepth 1 -maxdepth 1 -type d | head -1) # src/ + +# Clean git baseline so each injected violation can be reverted. +just init-whitelist >/dev/null 2>&1 || true +git add -A >/dev/null 2>&1 || true +git -c user.email=ci@example.com -c user.name=ci commit -q --no-verify -m "chore: baseline" >/dev/null 2>&1 || true + +PASS=0 +FAIL=0 +SKIP=0 +expect_fail() { name=$1; shift; if "$@" >/tmp/bite.log 2>&1; then echo " NOT CAUGHT: $name"; FAIL=$((FAIL + 1)); else echo " caught: $name"; PASS=$((PASS + 1)); fi; } +expect_pass() { name=$1; shift; if "$@" >/tmp/bite.log 2>&1; then echo " ok (clean): $name"; PASS=$((PASS + 1)); else echo " FALSE POS: $name"; FAIL=$((FAIL + 1)); fi; } +restore() { git checkout -q -- . 2>/dev/null; git clean -fdq 2>/dev/null; } +add_dep() { python3 -c "import pathlib;p=pathlib.Path('pyproject.toml');p.write_text(p.read_text().replace('dependencies = []','dependencies = [\"$1\"]'))"; } + +echo "=== guardrail-bite checks (package: $PKG) ===" + +# 1. ruff S rules still fire in src/ (the scripts/** S-ignore must not leak) +printf 'import subprocess\n\n\ndef run(cmd: str) -> None:\n """Run."""\n subprocess.run(cmd, shell=True)\n' > "$PKG/danger.py" +expect_fail "ruff S (subprocess shell=True) in src" just lint +restore + +# 2. deptry catches a missing (undeclared) dependency +printf 'import httpx\n\n\ndef go() -> int:\n """Go."""\n return len(httpx.__name__)\n' > "$PKG/usehttpx.py" +expect_fail "deptry DEP001 missing dependency" just deps +restore + +# 3. deptry catches an unused declared dependency (ignore list not over-broad) +add_dep "httpx>=0.27" +expect_fail "deptry DEP002 unused dependency" just deps +restore + +# 4. vulture catches new dead code not in the whitelist +printf '\n\ndef orphaned_never_used() -> None:\n """Orphan."""\n' >> "$PKG/__init__.py" +expect_fail "vulture new dead code" just dead-code +restore + +# 5. slop fails on a hallucinated package, passes on a real one +add_dep "reqeusts-not-a-real-pkg-xyz>=1" +expect_fail "slop hallucinated package" just slop +restore # reset dependencies = [] so the next add_dep applies cleanly +add_dep "requests>=2" +expect_pass "slop real package" just slop +restore + +# 6. xenon fails on a genuinely over-complex (grade C) function +cat > "$PKG/complex_mod.py" <<'PY' +def tangled(a: int, b: int, c: int, d: int) -> int: + """Deliberately over-complex.""" + t = 0 + if a > 0: + t += 1 + if b > 0: + t += 1 + if c > 0: + t += 1 + if d > 0: + t += 1 + for i in range(a): + if i % 2 == 0 and b > 1: + t += 1 + elif i % 3 == 0 or c > 2: + t += 2 + elif i % 5 == 0 and d > 3: + t += 3 + else: + t -= 1 + while b > 0: + b -= 1 + if b == c: + break + if b == d: + continue + return t +PY +expect_fail "xenon complexity (grade C)" just complexity +restore + +# 7. type-complexity fails on a deeply nested annotation +printf 'x: dict[str, list[dict[str, tuple[int, str, float]]]] = {}\n' > "$PKG/deepann.py" +expect_fail "type-complexity nested annotation" just type-complexity +restore + +# 8. gitleaks blocks a real (non-example) secret — only if the binary is present +if command -v gitleaks >/dev/null; then + # Assemble the fake token at runtime so THIS script never contains a + # contiguous ghp_<36> literal (GitHub push protection would block committing + # it). At runtime leak.py gets the full token, which gitleaks then detects. + printf 'TOKEN = "ghp_%s"\n' 'wWPw5k4aXcaT4fNP0UcnZwJUVFk6LO0pINUx' > leak.py + git add leak.py + if gitleaks git --staged >/tmp/bite.log 2>&1; then echo " NOT CAUGHT: gitleaks real secret"; FAIL=$((FAIL + 1)); else echo " caught: gitleaks real secret"; PASS=$((PASS + 1)); fi + git reset -q leak.py; rm -f leak.py +else + echo " skipped: gitleaks real secret (binary not installed)"; SKIP=$((SKIP + 1)) +fi + +echo "=== $PASS caught, $FAIL not caught, $SKIP skipped ===" +[ "$FAIL" -eq 0 ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4ee1906 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,122 @@ +# The template's OWN CI (the template repo has none otherwise). Renders the +# template with copier and runs the generated project's floor, so the template +# can't silently ship a broken guardrail. This is separate from the release +# pipeline, which only runs inside generated projects. +name: CI +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + render-and-floor: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + license: ["MIT"] + include: + - python-version: "3.12" + license: "none" # guards the license=none rendering fix + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + - uses: extractions/setup-just@v3 + - name: Render template (py ${{ matrix.python-version }}, license ${{ matrix.license }}) + run: | + # Render from a non-git copy of the working tree (avoids copier's VCS + # ref resolution and any shallow-clone edge cases). + mkdir -p "$RUNNER_TEMP/src" + cp copier.yml "$RUNNER_TEMP/src/" + cp -r template "$RUNNER_TEMP/src/template" + uvx copier copy --defaults \ + --data project_name='CI Smoke' \ + --data github_username=ci \ + --data author_name='CI' \ + --data author_email='ci@example.com' \ + --data python_version=${{ matrix.python-version }} \ + --data license=${{ matrix.license }} \ + "$RUNNER_TEMP/src" "$RUNNER_TEMP/render" + - name: Initialize git in the render (hotspots churn needs git log) + working-directory: ${{ runner.temp }}/render + run: | + git init -q -b main + git config user.email ci@example.com + git config user.name ci + git add -A + git commit -q -m 'chore: initial render' + - name: uv sync + working-directory: ${{ runner.temp }}/render + run: uv sync + - name: Seed vulture allowlist + working-directory: ${{ runner.temp }}/render + run: just init-whitelist + - name: Floor - check + working-directory: ${{ runner.temp }}/render + run: just check + - name: Floor - audit + working-directory: ${{ runner.temp }}/render + run: just audit + - name: Floor - slop + working-directory: ${{ runner.temp }}/render + run: just slop + - name: Floor - insights + working-directory: ${{ runner.temp }}/render + run: just insights + - name: Assert license=none rendering + if: matrix.license == 'none' + working-directory: ${{ runner.temp }}/render + run: | + grep -qF 'All rights reserved.' README.md + ! grep -qxF 'none' README.md + ! grep -q '^license = ' pyproject.toml + + guardrails-bite: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + - uses: extractions/setup-just@v3 + - name: Render template + run: | + mkdir -p "$RUNNER_TEMP/src" + cp copier.yml "$RUNNER_TEMP/src/" + cp -r template "$RUNNER_TEMP/src/template" + uvx copier copy --defaults \ + --data project_name='CI Smoke' \ + --data github_username=ci \ + --data author_name='CI' \ + --data author_email='ci@example.com' \ + --data python_version='3.12' \ + --data license=MIT \ + "$RUNNER_TEMP/src" "$RUNNER_TEMP/render" + - name: Initialize git + sync + working-directory: ${{ runner.temp }}/render + run: | + git init -q -b main + git config user.email ci@example.com + git config user.name ci + git add -A + git commit -q -m 'chore: initial render' + uv sync + - name: Assert every guardrail bites on a real violation + run: sh .github/scripts/assert-guardrails-bite.sh "$RUNNER_TEMP/render" + + secrets: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history for a complete secret scan + - uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # required for PR scans diff --git a/template/README.md.jinja b/template/README.md.jinja index d11b505..4c39a75 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -51,4 +51,4 @@ high-risk files. ## License -{{ license }} +{% if license == 'none' %}All rights reserved.{% else %}{{ license }}{% endif %} diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index bfd6621..8aafdac 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -2,6 +2,9 @@ name = "{{ project_slug }}" version = "0.1.0" description = "{{ description }}" +{% if license != 'none' -%} +license = "{{ license }}" +{% endif -%} authors = [{ name = "{{ author_name }}", email = "{{ author_email }}" }] readme = "README.md" requires-python = ">={{ python_version }}"