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
50 changes: 50 additions & 0 deletions .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: changelog

on:
pull_request:
# `types:` REPLACES the defaults (opened, synchronize, reopened), so they are listed
# alongside labeled/unlabeled: without the label events, adding `skip-changelog`
# to an open PR would never re-run this check.
types: [opened, synchronize, reopened, labeled, unlabeled]

permissions:
contents: read

concurrency:
group: changelog-${{ github.ref }}
cancel-in-progress: true

jobs:
changelog:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Full history so the base branch is reachable for the three-dot diff.
fetch-depth: 0
persist-credentials: false
- name: Require a CHANGELOG entry for template-visible changes
# The label is honoured inside the step, not in a job-level `if:`, so the check
# always reports a pass — a job skipped by `if:` shows as "skipped", which blocks
# the merge when this check is required.
env:
BASE_REF: ${{ github.base_ref }}
SKIP: ${{ contains(github.event.pull_request.labels.*.name, 'skip-changelog') }}
run: |
if [ "$SKIP" = "true" ]; then
echo "changelog: skip-changelog label present; entry not required"
exit 0
fi
base="origin/$BASE_REF"
# Fail closed: an unresolvable base (a shallow checkout) must not pass vacuously.
git rev-parse --verify --quiet "$base" >/dev/null \
|| { echo "::error::cannot resolve $base; the checkout needs fetch-depth: 0"; exit 1; }
# "An entry" means at least one non-empty added line — a deletion or a
# whitespace-only touch of CHANGELOG.md does not count.
if [ -n "$(git diff --name-only "$base...HEAD" -- template copier.yml)" ] \
&& [ -z "$(git diff -U0 "$base...HEAD" -- CHANGELOG.md | grep '^+[^+]')" ]; then
echo "::error::template/ or copier.yml changed without a new CHANGELOG.md line; add an entry under [Unreleased] or label the PR skip-changelog"
exit 1
fi
echo "changelog: ok"
16 changes: 11 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ This repo is a Copier template. `template/` holds the generated project as `.jin
just ci # fmt-check + lint + typecheck + test + policy + audit, then "ci: all gates passed"
```

`just ci` is the complete local gate — `ci: fmt-check lint typecheck test policy audit` ending `@echo "ci: all gates passed"`, with `verify` a bare alias (`verify: ci`). It **mirrors** `template/justfile.jinja`'s `ci`: the template chains `policy` and `audit` when `enable_policy_tests`/`enable_dependency_audit` are on (they are), so the faithful maintainer recipe includes both. `test` is the heavy generation matrix, so `just ci` needs a roomy `TMPDIR` — the default 4G tmpfs `/tmp` can overflow (export e.g. `TMPDIR=/path/to/roomy/dir` first). The fast inner loop is `just fmt-check lint typecheck` (one invocation, no matrix, no network); there is deliberately **no** `check:` recipe (the template ships none). `audit` is non-hermetic (it queries the OSV/PyPI advisory DB), so `just ci` reaches the network exactly as `just audit` does; scanners (`just scan`) deliberately stay **off** `ci` (CI-only), matching the template.
`just ci` is the complete local gate — `ci: fmt-check lint typecheck test policy audit` ending `@echo "ci: all gates passed"`, with `verify` a bare alias (`verify: ci`). It **mirrors** `template/justfile.jinja`'s `ci`: the template chains `policy` and `audit` when `enable_policy_tests`/`enable_dependency_audit` are on (they are), so the faithful maintainer recipe includes both. `test` is the heavy generation matrix, so `just ci` needs a roomy `TMPDIR` — the default 4G tmpfs `/tmp` can overflow (export e.g. `TMPDIR=/path/to/roomy/dir` first). The fast inner loop is `just fmt-check lint typecheck` (one invocation, no matrix, no network); there is deliberately **no** `check:` recipe (the template ships none). `audit` is non-hermetic (it queries the OSV/PyPI advisory DB), so `just ci` reaches the network exactly as `just audit` does; scanners (`just scan`) deliberately stay **off** `ci` (CI-only), matching the template, as does the changelog check (`changelog.yml`, no recipe — see "Changelog check").

This maintainer `just ci` is distinct from the **downstream's** `just ci` referenced under "Run the tests": `just test` renders each project in the answer matrix and runs *its* `just ci` — a different, generated recipe.

CI does **not** run `just ci`. It keeps parallel per-gate jobs (`test`/`typecheck`/`lint`/`scan`), each inlining its command; only `test` fans out over the OS×Python matrix, and the `scan` job runs `uvx`/`mise exec` (not `uv run`); because every gate is enforced independently (audit via the `scan` job's `pip-audit` step), `just ci` here is the local reproduction of the PR gate, not the CI entry point. Pointing the CI jobs at these recipes (the gap-audit "candidate F") is deferred: the `scan` job aggregates pip-audit + semgrep + gitleaks as three steps that don't map onto the `audit`+`scan` recipe split, so it is a real restructure, not a one-line swap, and the follow-up must reconcile the recipe encoding with the parallel jobs.
CI does **not** run `just ci`. It keeps parallel per-gate jobs (`test`/`typecheck`/`lint`/`scan`, plus the PR-only `changelog` workflow — see "Changelog check"), each inlining its command; only `test` fans out over the OS×Python matrix, and the `scan` job runs `uvx`/`mise exec` (not `uv run`); because every gate is enforced independently (audit via the `scan` job's `pip-audit` step), `just ci` here is the local reproduction of the PR gate, not the CI entry point. Pointing the CI jobs at these recipes (the gap-audit "candidate F") is deferred: the `scan` job aggregates pip-audit + semgrep + gitleaks as three steps that don't map onto the `audit`+`scan` recipe split, so it is a real restructure, not a one-line swap, and the follow-up must reconcile the recipe encoding with the parallel jobs.

**Forward-sync:** the policy-tests layer (gap #4) has landed — `policy` is now a `ci` member (see "Policy gate"). When the property-tests layer (gap #9) lands, add `fuzz` to **both** the `ci` recipe **and** this section, to keep mirroring the template's conditional `ci` (`fmt-check lint typecheck test{% if enable_property_tests %} fuzz{% endif %}{% if enable_policy_tests %} policy{% endif %}{% if enable_dependency_audit %} audit{% endif %}`).

Expand Down Expand Up @@ -64,7 +64,7 @@ just scan # out-of-band secret + SAST scan: semgrep (no-eval) + gitleaks (full

`just scan` runs semgrep's `no-eval` rule and a gitleaks **full-history** secret scan (`.gitleaks.toml` = default ruleset). It is out-of-band (chained into no recipe), but CI enforces it: the `scan` job in `.github/workflows/test-template.yml` is a blocking PR gate. gitleaks is pinned in `mise.toml` (`gitleaks = "8.30.1"`) and installed in CI via `jdx/mise-action` + `mise exec`; semgrep runs via `uvx semgrep@1.167.0` (no dep, like zizmor). **semgrep scans non-test Python only** — its built-in `.semgrepignore` excludes `tests/`, and there is no `src/`, so on this repo it currently scans **0 files** (a forward guard that mirrors the shipped gate and fires the moment any non-test Python is added at root); gitleaks scans the whole tree + full history regardless of language and is the substantive gate here. Never pass semgrep `--config auto` (it drops the pinned rule and needs metrics on); never hardcode the gitleaks version in CI (install via `mise exec`).

Deliberate divergences from the template's `scan.yml` (`template/{% if not in_existing_repo %}.github{% endif %}/workflows/…scan.yml….jinja`): the maintainer folds scanning into the existing `test-template.yml` as a sibling `scan` job (the template consolidates into a standalone `scan.yml`), matching the one-workflow / per-tool layout and letting the existing zizmor job audit it; zizmor stays its own job here rather than a step in `scan` (already dogfooded standalone). The CI `mise-action` comment drops the template's "kept fresh by Renovate" note; the maintainer's `mise.toml` pin is Renovate-managed too (see "Renovate"), but the comment names only the single-source-of-truth property.
Deliberate divergences from the template's `scan.yml` (`template/{% if not in_existing_repo %}.github{% endif %}/workflows/…scan.yml….jinja`): the maintainer folds scanning into the existing `test-template.yml` as a sibling `scan` job (the template consolidates into a standalone `scan.yml`), matching the one-workflow / per-tool layout and letting the existing zizmor job audit it (the changelog check is the one exception: its trigger types need a separate workflow — see "Changelog check"); zizmor stays its own job here rather than a step in `scan` (already dogfooded standalone). The CI `mise-action` comment drops the template's "kept fresh by Renovate" note; the maintainer's `mise.toml` pin is Renovate-managed too (see "Renovate"), but the comment names only the single-source-of-truth property.

Renovate re-derives only the `mise.toml` gitleaks pin (see "Renovate"); every other literal — the prose here, the template copies, and the uvx-run semgrep — is hand-synced (the generation drift test reads only the *rendered* downstream), so **bump every literal site by hand, against the template.** gitleaks (`8.30.1`) has two maintainer sites — `mise.toml` and the prose above — synced to `template/mise.toml.jinja` (CI installs via `mise exec`, so there is no third gitleaks literal); `just policy` asserts all three agree, so a Renovate gitleaks PR stays red until the template copy and this prose are bumped on the same branch. semgrep (`1.167.0`) has three — the `just scan` recipe, the `scan` job in `test-template.yml`, and the prose above — synced to `template/justfile.jinja` and the template `scan.yml`. (Mirrors the pre-commit "bump both `rev:` pins together" obligation.)

Expand All @@ -88,6 +88,12 @@ Renovate sees only the maintainer's own files, never `template/*.jinja`. A gitle

Deliberate divergences from the template's `renovate.json`: the `pre-commit` manager is off (above); the `uvx` regex `customManager` is omitted — the only maintainer `uvx` pins are parity-locked to the rendered template by the generation suite, so a one-sided bump would fail `just test`; and the `astral-sh/uv` rule is maintainer-only (the template ships no such rule; its rendered `uv` pin is equally multi-site, not addressed here).

## Changelog check

`.github/workflows/changelog.yml` fails a PR that changes `template/**` or `copier.yml` without adding at least one non-empty line to `CHANGELOG.md` (an entry under `## [Unreleased]`; a deletion or whitespace-only touch does not count), unless the PR carries the `skip-changelog` label (created once by hand: `gh label create skip-changelog`). It is CI-only (no recipe — a base-ref diff has no local form) and PR-only (no `push` trigger), and it fails closed: the base ref is verified before the diff, so a shallow checkout errors instead of passing vacuously. Three further mechanics are load-bearing: `types:` lists all five activity types because it *replaces* the defaults and a bare `pull_request` never re-runs on a label change; the label is honoured inside the step rather than a job-level `if:`, so the check reports pass, never "skipped" (a skipped required status blocks the merge); and `github.base_ref` reaches the script through `env:` (zizmor flags it inline in `run:` as template-injection). Maintainer CI changes (`.github/workflows/**`) need no entry — the CHANGELOG describes generated-project behaviour only — so Renovate's Action bumps pass untouched.

This is the first Phase 2 (net-new) layer of the dogfooding audit: added to the template first (`enable_changelog`), then dogfooded here in the same PR; `just policy` pins the maintainer copy's trigger types, pathspec, and env-mapped base ref (`test_changelog_check_literals`), since the generation suite exercises only the rendered template's script. Deliberate divergences from the template's `changelog.yml`: the trigger paths are `template/**` + `copier.yml` (the template's are `src/**` + `pyproject.toml`), and the Action pin is the maintainer's own (Renovate-managed — see "Renovate").

## Policy gate (`just policy`)

```bash
Expand All @@ -108,7 +114,7 @@ The SHA-pin sub-check overlaps the zizmor job (the security control), so its net

1. Add an `enable_*` toggle to `copier.yml`.
2. Add the conditional file(s) under `template/` (file: `{% if flag %}name{% endif %}.jinja`; dir: `{% if flag %}dir{% endif %}/`).
3. Wire it into `template/justfile.jinja` (a recipe; add it as a `ci` dep only for a *gating* layer — out-of-band checks like `scan`/`mutate` ship a recipe but stay off `ci`, and CI-only layers like renovate/sha-pin add no recipe at all). Then, where applicable: a dep in `template/pyproject.toml.jinja` (skip it for `uvx`-run tools like the scanners), a section in `template/AGENTS.md.jinja`, and a CI surface under the template's `.github/workflows/` (a conditional step in `scan.yml`, or a dedicated conditional workflow file via the empty-name idiom). Root-only files — the `.github/` dir, `.pre-commit-config.yaml`, `renovate.json` — carry `not in_existing_repo` in their path condition (GitHub and Renovate read them only at a repository root); a new root-only file must too, and `test_existing_repo_layer`'s omission list grows with it.
3. Wire it into `template/justfile.jinja` (a recipe; add it as a `ci` dep only for a *gating* layer — out-of-band checks like `scan`/`mutate` ship a recipe but stay off `ci`, and CI-only layers like renovate/sha-pin/changelog add no recipe at all). Then, where applicable: a dep in `template/pyproject.toml.jinja` (skip it for `uvx`-run tools like the scanners), a section in `template/AGENTS.md.jinja`, and a CI surface under the template's `.github/workflows/` (a conditional step in `scan.yml`, or a dedicated conditional workflow file via the empty-name idiom). Root-only files — the `.github/` dir, `.pre-commit-config.yaml`, `renovate.json` — carry `not in_existing_repo` in their path condition (GitHub and Renovate read them only at a repository root); a new root-only file must too, and `test_existing_repo_layer`'s omission list grows with it.
4. Extend `tests/test_generation.py`: assert present-when-on AND absent-when-off, and that the layer's gate passes.

## Release
Expand All @@ -121,7 +127,7 @@ git push origin v0.1.0
git describe --tags # verify a reachable tag now exists (must succeed)
```

Update `CHANGELOG.md` (promote the `Unreleased` entries under the new version) in the release commit before tagging.
Update `CHANGELOG.md` (promote the `Unreleased` entries under the new version) in the release commit before tagging; the changelog check (see "Changelog check") makes a missing entry a red PR, so when promoting, only PRs merged with the `skip-changelog` label need a second look.

Breaking renames/moves need a version-gated `_migrations` entry.

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `enable_changelog` toggle (default `true`): ships a Keep-a-Changelog `CHANGELOG.md` and a
PR-only `changelog.yml` check that fails when `src/` or `pyproject.toml` change without a
new `CHANGELOG.md` line; the `skip-changelog` label bypasses it. CI-only, no recipe.

## [0.2.0] - 2026-09-02

### Added
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ All toggles default to `true` — every guardrail layer ships unless you opt out
| `enable_dependency_audit` | pip-audit recipe (`just audit`) |
| `enable_renovate` | Renovate bot config |
| `enable_sha_pin_policy` | zizmor workflow audit in CI, plus a CI policy test asserting all Action SHAs are pinned — the policy test requires `enable_policy_tests` (only the zizmor audit ships otherwise) |
| `enable_changelog` | `CHANGELOG.md` plus a PR-only `changelog.yml` check: a PR that touches `src/` or `pyproject.toml` must also add a line to `CHANGELOG.md`, or carry the `skip-changelog` label |

Beyond the local recipes, several toggles also emit GitHub Actions workflows: `enable_scanners`, `enable_dependency_audit`, and `enable_sha_pin_policy` each contribute steps to a generated `scan.yml` (semgrep + gitleaks, pip-audit, and zizmor respectively), and `enable_mutation_tests` emits a scheduled `mutation.yml`. The always-on `ci.yml` runs the `just ci` gate on a Python-version matrix covering every interpreter at or above the project's `requires-python` floor (a single leg for the `3.13` default).
Beyond the local recipes, several toggles also emit GitHub Actions workflows: `enable_scanners`, `enable_dependency_audit`, and `enable_sha_pin_policy` each contribute steps to a generated `scan.yml` (semgrep + gitleaks, pip-audit, and zizmor respectively), `enable_mutation_tests` emits a scheduled `mutation.yml`, and `enable_changelog` emits a PR-only `changelog.yml`. The always-on `ci.yml` runs the `just ci` gate on a Python-version matrix covering every interpreter at or above the project's `requires-python` floor (a single leg for the `3.13` default).

## Requirements

Expand Down
3 changes: 3 additions & 0 deletions copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,9 @@ enable_renovate:
enable_sha_pin_policy:
type: bool
default: true
enable_changelog:
type: bool
default: true

# Hidden helper (never prompted). The generation-test harness passes this as False
# (see tests/conftest.py) to skip the slow pre-commit hook-install task; a real
Expand Down
2 changes: 1 addition & 1 deletion docs/superpowers/plans/2026-07-01-dogfood-gap-audit.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Dogfooding-gap audit — python-kickstarter (2026-07-01)

> **Status (2026-09-08).** Phase 1 landed: gap 1 (PR #6), gap 2 (#7), gap 5 (#8), gaps 7 + 3 (#9), gaps 4 + 8 (#10), gap 6 (`chore/dogfood-renovate`; the Mend app install is the operator step). **Gap 9 deferred:** copier's validators are prompt-time Jinja, not importable, so fuzzing them costs one copier render per example, and the TOML escaper already has round-trip tests. **Candidate F deferred:** the `scan` CI job bundles pip-audit + semgrep + gitleaks as three steps that do not map onto the `audit`/`scan` recipe split (see AGENTS.md "Run every gate"). Phases 2 and 3 not started; first Phase 2 layer is the CHANGELOG-`[Unreleased]` PR check.
> **Status (2026-09-08).** Phase 1 landed: gap 1 (PR #6), gap 2 (#7), gap 5 (#8), gaps 7 + 3 (#9), gaps 4 + 8 (#10), gap 6 (`chore/dogfood-renovate`; the Mend app install is the operator step). **Gap 9 deferred:** copier's validators are prompt-time Jinja, not importable, so fuzzing them costs one copier render per example, and the TOML escaper already has round-trip tests. **Candidate F deferred:** the `scan` CI job bundles pip-audit + semgrep + gitleaks as three steps that do not map onto the `audit`/`scan` recipe split (see AGENTS.md "Run every gate"). **Phase 2 started (2026-09-10):** the CHANGELOG-`[Unreleased]` PR check landed as the `enable_changelog` template layer and was dogfooded in the same PR (#26, `feat/changelog-check`; release pending). Phase 3 not started.

## 1. Executive summary

Expand Down
3 changes: 2 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ default:
# both; each is also an independent PR-blocking check here — policy via the CI `test` job's
# pytest collection, audit via the CI `scan` job's pip-audit step).
# `test` is the full generation matrix: give it a roomy TMPDIR (the default 4G tmpfs
# /tmp can overflow) — see "Run every gate" in AGENTS.md. Scanners stay CI-only (off `ci`).
# /tmp can overflow) — see "Run every gate" in AGENTS.md. Scanners and the changelog check
# stay CI-only (off `ci`).
ci: fmt-check lint typecheck test policy audit
@echo "ci: all gates passed"

Expand Down
Loading