Project template: honest CI, gitleaks config, protection bootstrap
CI / secret-scan (push) Successful in 18s
CI / python-ci (push) Failing after 1m0s

This commit is contained in:
2026-07-26 15:35:32 -04:00
commit ed6edab998
10 changed files with 366 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# Copy to .env (gitignored) and fill in. Real secrets belong in Infisical:
# https://secrets.levkin.ca -> LevkinOps -> /apps/<this-repo>
EXAMPLE_API_KEY=changeme
+48
View File
@@ -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
+54
View File
@@ -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/
+36
View File
@@ -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$''',
]
+21
View File
@@ -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.
+56
View File
@@ -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/<name>
cd ~/Documents/code/<name>
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 <name> 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/<repo>`) 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
Executable
+52
View File
@@ -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 <repo-name> <python|node> [--public]
set -euo pipefail
NAME="${1:?usage: bootstrap.sh <repo-name> <python|node> [--public]}"
LANE="${2:?usage: bootstrap.sh <repo-name> <python|node> [--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 <pr>"
+41
View File
@@ -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
+48
View File
@@ -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
+7
View File
@@ -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"]