From ed6edab998b5181bdeea2285307e5a75f8ff61fc Mon Sep 17 00:00:00 2001 From: ilia Date: Sun, 26 Jul 2026 15:35:32 -0400 Subject: [PATCH] Project template: honest CI, gitleaks config, protection bootstrap --- .env.example | 3 ++ .gitea/workflows/ci.yml | 48 ++++++++++++++++++++++++++++++++ .gitignore | 54 ++++++++++++++++++++++++++++++++++++ .gitleaks.toml | 36 ++++++++++++++++++++++++ LICENSE | 21 ++++++++++++++ README.md | 56 ++++++++++++++++++++++++++++++++++++++ bootstrap.sh | 52 +++++++++++++++++++++++++++++++++++ ci-templates/ci-node.yml | 41 ++++++++++++++++++++++++++++ ci-templates/ci-python.yml | 48 ++++++++++++++++++++++++++++++++ ruff.toml | 7 +++++ 10 files changed, 366 insertions(+) create mode 100644 .env.example create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .gitleaks.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100755 bootstrap.sh create mode 100644 ci-templates/ci-node.yml create mode 100644 ci-templates/ci-python.yml create mode 100644 ruff.toml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fa02a8c --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Copy to .env (gitignored) and fill in. Real secrets belong in Infisical: +# https://secrets.levkin.ca -> LevkinOps -> /apps/ +EXAMPLE_API_KEY=changeme diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..d1ee41c --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,48 @@ +--- +# Homelab CI — Python lane + secret scan. +# POLICY: lint and tests are HARD gates. Never add `|| true` to them. +name: CI + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +jobs: + python-ci: + runs-on: [homelab, self-hosted, linux, python] + container: + image: node:20-bookworm + steps: + - uses: actions/checkout@v4 + + - name: Install Python tooling + run: | + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3 python3-pip python3-venv + python3 -m pip install --upgrade pip --break-system-packages + if [ -f requirements.txt ]; then pip install -r requirements.txt --break-system-packages; fi + if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt --break-system-packages; fi + pip install pytest ruff --break-system-packages + + - name: Ruff lint (hard gate) + run: ruff check . + + - name: Pytest (hard gate) + run: pytest -q + + # Advisory-only scanners (allowed to be soft — they're noisy, not gates) + - name: Bandit (advisory) + run: pip install bandit --break-system-packages && bandit -r . -q || true + + secret-scan: + runs-on: [homelab, self-hosted, linux, heavy] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Gitleaks + run: | + docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \ + detect --source /repo --config /repo/.gitleaks.toml --no-banner --redact diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ab8614 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# --- OS / editor --- +.DS_Store +Thumbs.db +.vscode/ +.idea/ +*.swp + +# --- Secrets & env (real values never in git) --- +.env +.env.* +!.env.example +*.pem +*.key +*password* +*credential* +!*example* + +# --- Real data (commit *.example.* fixtures instead) --- +data/*.json +data/*.yml +data/*.yaml +!data/*.example.* +*.local.yml +*.local.json + +# --- Python --- +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# --- Node --- +node_modules/ +dist/ +coverage/ +npm-debug.log* + +# --- Outputs / scratch --- +out/ +tmp/ +.tmp/ +scratch/ +*.log + +# --- Agent tooling (local, not for the repo) --- +.cursor/ +.claude/ +.codex/ +.agents/ +.opencode/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..1d64149 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,36 @@ +# Repo gitleaks config — same rules as the global fallback +# (~/.config/git/gitleaks.toml) so local hook and CI agree. + +title = "repo gitleaks" + +[extend] +useDefault = true + +[[rules]] +id = "env-file-committed" +description = "Raw .env file (or backup/variant) staged for commit" +path = '''(^|/)\.env(\.(backup|bak|old|orig|save|local|prod|production|dev|development|staging))?$''' + +[[rules]] +id = "password-or-credential-file" +description = "File name indicates stored password/credential material" +path = '''(?i)(^|/)[^/]*(password|passwd|credential)[^/]*\.(txt|json|ya?ml|env|cfg|ini)$''' + +[[rules]] +id = "private-key-file" +description = "Private key or keystore file staged for commit" +path = '''(^|/)(id_rsa|id_ed25519|id_ecdsa)[^/]*$|\.(pem|p12|pfx|key)$''' + +[[rules]] +id = "dotfile-secret-token" +description = "Hidden standalone secret/token file" +path = '''(^|/)\.[^/]*(secret|token)[^/]*$''' + +[allowlist] +description = "Legitimate non-secret files matching the patterns above" +paths = [ + '''(?i)\.(example|sample|template|dist)$''', + '''(^|/)\.env\.example[^/]*$''', + '''(^|/)[^/]*\.pub$''', + '''(^|/)package(-lock)?\.json$''', +] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..96c887d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ilia Dobkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa0036a --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# project-template + +Clone-and-go skeleton for new projects. Every repo started from this template +gets, on day one: honest CI (lint + tests are **hard gates**, never `|| true`), +secret scanning, branch protection, LICENSE, and a gitignore that keeps real +data out of git. + +Born out of the 2026-07 portfolio hygiene sweep — see +`~/.cursor/rules/new-project-standards.mdc` for the standards this enforces. + +## Start a new project + +```bash +# 1. Copy the skeleton (don't clone-fork; you want fresh history) +cp -R ~/Documents/code/project-template ~/Documents/code/ +cd ~/Documents/code/ +rm -rf .git && git init -b main + +# 2. Pick your CI lane and drop the other +cp ci-templates/ci-python.yml .gitea/workflows/ci.yml # or ci-node.yml +rm -rf ci-templates + +# 3. Fill in README/LICENSE year, write code, commit + +# 4. Create the Gitea repo with protection + settings in one shot +./bootstrap.sh python # or: node +``` + +`bootstrap.sh` creates the (private by default) repo on `git.levkin.ca`, +enables delete-branch-after-merge, pushes `main`, and adds branch protection +requiring the CI contexts. Make a repo public only after it passes the +public-repo checklist below. + +## Rules baked in + +- **No `|| true` on lint or test steps.** If a gate is red, fix it or narrow + the config with a comment — never mute the gate. +- **Real data never in fixtures.** Commit `*.example.json` / `*.example.yml` + with fake values; real files are gitignored (see `.gitignore`). +- **No secrets in git, ever.** Secrets live in Infisical + (`secrets.levkin.ca`, LevkinOps → `/apps/`) or local gitignored + `.env`. The global pre-commit hook (gitleaks + filename denylist) is the + last line of defense, not the first. +- **No absolute home paths in code.** Use env vars or `Path.home()` / + `process.env.HOME`. +- **No LAN IPs / homelab topology in public docs.** Use placeholders; deploy + runbooks live in the private ansible repo. + +## Public-repo checklist (before flipping `private: false`) + +- [ ] LICENSE present, README explains what/why/how for an outsider +- [ ] `rg "10\.0\.10\."` returns nothing +- [ ] `rg "/Users/"` returns nothing +- [ ] No real names/addresses/phones except your intentional public persona +- [ ] gitleaks full-history scan clean: `gitleaks detect --source .` +- [ ] CI green with hard gates diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..2e9abea --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Create the Gitea repo for this project with gates preconfigured: +# private repo, delete-branch-after-merge, branch protection on main +# requiring the CI contexts, initial push. +# +# Usage: ./bootstrap.sh [--public] +set -euo pipefail + +NAME="${1:?usage: bootstrap.sh [--public]}" +LANE="${2:?usage: bootstrap.sh [--public]}" +PRIVATE=true +[[ "${3:-}" == "--public" ]] && PRIVATE=false + +GITEA="https://git.levkin.ca" +OWNER="ilia" +# shellcheck disable=SC1090 +source "$HOME/.config/hermes/gitea.env" # provides GITEA_TOKEN + +api() { + local method="$1" path="$2" body="${3:-}" + curl -sf -X "$method" -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" ${body:+-d "$body"} "${GITEA}/api/v1${path}" +} + +case "$LANE" in + python) CONTEXTS='["CI / python-ci (pull_request)","CI / secret-scan (pull_request)"]' ;; + node) CONTEXTS='["CI / node-ci (pull_request)","CI / secret-scan (pull_request)"]' ;; + *) echo "lane must be python or node" >&2; exit 1 ;; +esac + +if [[ ! -f ".gitea/workflows/ci.yml" ]]; then + echo "ERROR: .gitea/workflows/ci.yml missing — copy one from ci-templates/ first." >&2 + exit 1 +fi + +echo "Creating ${OWNER}/${NAME} (private=${PRIVATE})..." +api POST /user/repos "{\"name\":\"${NAME}\",\"private\":${PRIVATE},\"default_branch\":\"main\"}" >/dev/null + +echo "Enabling delete-branch-after-merge..." +api PATCH "/repos/${OWNER}/${NAME}" '{"default_delete_branch_after_merge": true}' >/dev/null + +echo "Pushing main..." +git remote add origin "${GITEA}/${OWNER}/${NAME}.git" 2>/dev/null || true +git push "https://${OWNER}:${GITEA_TOKEN}@git.levkin.ca/${OWNER}/${NAME}.git" main + +echo "Adding branch protection (required contexts: ${CONTEXTS})..." +api POST "/repos/${OWNER}/${NAME}/branch_protections" \ + "{\"branch_name\":\"main\",\"rule_name\":\"main\",\"enable_push\":false,\"enable_status_check\":true,\"status_check_contexts\":${CONTEXTS},\"required_approvals\":0}" >/dev/null + +echo "Done: ${GITEA}/${OWNER}/${NAME}" +echo "Work via PRs from now on; merge with:" +echo " GITEA_REPO=${NAME} bash ~/Documents/code/hermes/scripts/gitea-merge-when-green.sh " diff --git a/ci-templates/ci-node.yml b/ci-templates/ci-node.yml new file mode 100644 index 0000000..dd349e0 --- /dev/null +++ b/ci-templates/ci-node.yml @@ -0,0 +1,41 @@ +--- +# Homelab CI — Node lane + secret scan. +# POLICY: lint, typecheck, and tests are HARD gates. Never add `|| true` to them. +name: CI + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +jobs: + node-ci: + runs-on: [homelab, self-hosted, linux] + container: + image: node:20-bookworm + steps: + - uses: actions/checkout@v4 + + - name: Install + run: npm ci + + - name: Lint (hard gate) + run: npm run lint --if-present + + - name: Typecheck (hard gate) + run: npm run typecheck --if-present + + - name: Test (hard gate) + run: npm test + + secret-scan: + runs-on: [homelab, self-hosted, linux, heavy] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Gitleaks + run: | + docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \ + detect --source /repo --config /repo/.gitleaks.toml --no-banner --redact diff --git a/ci-templates/ci-python.yml b/ci-templates/ci-python.yml new file mode 100644 index 0000000..d1ee41c --- /dev/null +++ b/ci-templates/ci-python.yml @@ -0,0 +1,48 @@ +--- +# Homelab CI — Python lane + secret scan. +# POLICY: lint and tests are HARD gates. Never add `|| true` to them. +name: CI + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +jobs: + python-ci: + runs-on: [homelab, self-hosted, linux, python] + container: + image: node:20-bookworm + steps: + - uses: actions/checkout@v4 + + - name: Install Python tooling + run: | + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3 python3-pip python3-venv + python3 -m pip install --upgrade pip --break-system-packages + if [ -f requirements.txt ]; then pip install -r requirements.txt --break-system-packages; fi + if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt --break-system-packages; fi + pip install pytest ruff --break-system-packages + + - name: Ruff lint (hard gate) + run: ruff check . + + - name: Pytest (hard gate) + run: pytest -q + + # Advisory-only scanners (allowed to be soft — they're noisy, not gates) + - name: Bandit (advisory) + run: pip install bandit --break-system-packages && bandit -r . -q || true + + secret-scan: + runs-on: [homelab, self-hosted, linux, heavy] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Gitleaks + run: | + docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \ + detect --source /repo --config /repo/.gitleaks.toml --no-banner --redact diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..9c671d0 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,7 @@ +# Lint gate config — enforced in CI (no || true). Start pragmatic; tighten +# per-project by extending `select`, never by muting the CI step. +target-version = "py311" +line-length = 120 + +[lint] +select = ["E4", "E7", "E9", "F", "I"]