From 64c88877c98bcb9cfa5cf9fa91de694a4c7f9386 Mon Sep 17 00:00:00 2001 From: Ashlen Date: Thu, 10 Sep 2026 13:26:55 -0600 Subject: [PATCH 1/5] feat(template): add the enable_changelog layer Ships a Keep-a-Changelog CHANGELOG.md and a PR-only changelog.yml that fails when src/ or pyproject.toml change without a CHANGELOG.md change, unless the PR carries the skip-changelog label. CI-only; no recipe, since a base-ref diff has no local form. Three mechanics are deliberate and tested: `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"; and github.base_ref reaches the script through env:, which zizmor requires. The generation suite asserts present-when-on, absent-when-off, and runs the rendered script against a scratch clone in the blocking, labelled, and entry-added cases. run_in gains an env kwarg for that. --- .github/workflows/changelog.yml | 45 +++++++++++++ CHANGELOG.md | 6 ++ README.md | 3 +- copier.yml | 3 + template/AGENTS.md.jinja | 6 +- template/justfile.jinja | 2 +- ..._changelog %}CHANGELOG.md{% endif %}.jinja | 8 +++ ...changelog %}changelog.yml{% endif %}.jinja | 45 +++++++++++++ tests/conftest.py | 6 +- tests/test_generation.py | 64 +++++++++++++++++++ 10 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/changelog.yml create mode 100644 template/{% if enable_changelog %}CHANGELOG.md{% endif %}.jinja create mode 100644 template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_changelog %}changelog.yml{% endif %}.jinja diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..4887c79 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,45 @@ +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" + if [ -n "$(git diff --name-only "$base...HEAD" -- template copier.yml)" ] \ + && [ -z "$(git diff --name-only "$base...HEAD" -- CHANGELOG.md)" ]; then + echo "::error::template/ or copier.yml changed without a CHANGELOG.md entry; add one under [Unreleased] or label the PR skip-changelog" + exit 1 + fi + echo "changelog: ok" diff --git a/CHANGELOG.md b/CHANGELOG.md index dff597e..ab2bbcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 + `CHANGELOG.md` change; the `skip-changelog` label bypasses it. CI-only, no recipe. + ## [0.2.0] - 2026-09-02 ### Added diff --git a/README.md b/README.md index df39e80..cb11caa 100644 --- a/README.md +++ b/README.md @@ -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 touch `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 diff --git a/copier.yml b/copier.yml index 1d29a37..dfa6512 100644 --- a/copier.yml +++ b/copier.yml @@ -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 diff --git a/template/AGENTS.md.jinja b/template/AGENTS.md.jinja index f62136a..01f2d66 100644 --- a/template/AGENTS.md.jinja +++ b/template/AGENTS.md.jinja @@ -42,7 +42,11 @@ Put it in `tests/property/`. It MUST set `pytestmark = pytest.mark.property` and ## Dependencies `uv add ` (runtime) or `uv add --dev ` (tooling). `uv.lock` is committed; CI runs `uv sync --locked`. -{% if enable_policy_tests %} +{% if enable_changelog %} +## Changelog + +A PR that changes `src/` or `pyproject.toml` must add an entry under `## [Unreleased]` in `CHANGELOG.md` (CI: `changelog.yml`), or carry the `skip-changelog` label (create it once: `gh label create skip-changelog`). Promote the entries under a version heading in each release commit. +{% endif %}{% if enable_policy_tests %} ## Meta-guardrail Changing a gate (coverage floor, ruff select, type mode{% if enable_sha_pin_policy %}, Action pins{% endif %}) requires editing the matching test in `tests/policy/` in the SAME commit. diff --git a/template/justfile.jinja b/template/justfile.jinja index a09b9fc..971e66c 100644 --- a/template/justfile.jinja +++ b/template/justfile.jinja @@ -3,7 +3,7 @@ set shell := ["bash", "-eu", "-o", "pipefail", "-c"] default: @just --list -# The complete local gate — every PR-blocking check reproducible locally{% if enable_scanners %} (scanners run in CI only; see scan.yml){% endif %}. +# The complete local gate — every PR-blocking check reproducible locally{% if enable_scanners or enable_changelog %} (CI only:{% if enable_scanners %} scanners, see scan.yml{% endif %}{% if enable_scanners and enable_changelog %};{% endif %}{% if enable_changelog %} the changelog check, see changelog.yml{% endif %}){% endif %}. ci: fmt-check lint typecheck test{% if enable_property_tests %} fuzz{% endif %}{% if enable_policy_tests %} policy{% endif %}{% if enable_dependency_audit %} audit{% endif %} @echo "ci: all gates passed" diff --git a/template/{% if enable_changelog %}CHANGELOG.md{% endif %}.jinja b/template/{% if enable_changelog %}CHANGELOG.md{% endif %}.jinja new file mode 100644 index 0000000..6a888ba --- /dev/null +++ b/template/{% if enable_changelog %}CHANGELOG.md{% endif %}.jinja @@ -0,0 +1,8 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] diff --git a/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_changelog %}changelog.yml{% endif %}.jinja b/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_changelog %}changelog.yml{% endif %}.jinja new file mode 100644 index 0000000..333b269 --- /dev/null +++ b/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_changelog %}changelog.yml{% endif %}.jinja @@ -0,0 +1,45 @@ +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-{% raw %}${{ github.ref }}{% endraw %} + cancel-in-progress: true + +jobs: + changelog: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + 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 user-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: {% raw %}${{ github.base_ref }}{% endraw %} + SKIP: {% raw %}${{ contains(github.event.pull_request.labels.*.name, 'skip-changelog') }}{% endraw %} + run: | + if [ "$SKIP" = "true" ]; then + echo "changelog: skip-changelog label present; entry not required" + exit 0 + fi + base="origin/$BASE_REF" + if [ -n "$(git diff --name-only "$base...HEAD" -- src pyproject.toml)" ] \ + && [ -z "$(git diff --name-only "$base...HEAD" -- CHANGELOG.md)" ]; then + echo "::error::src/ or pyproject.toml changed without a CHANGELOG.md entry; add one under [Unreleased] or label the PR skip-changelog" + exit 1 + fi + echo "changelog: ok" diff --git a/tests/conftest.py b/tests/conftest.py index aa7ba98..ccd7b3b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -139,7 +139,9 @@ def _render(data: Mapping[str, object], dst: Path) -> Path: return _render -def run_in(project: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: +def run_in( + project: Path, *args: str, check: bool = True, env: dict[str, str] | None = None +) -> subprocess.CompletedProcess[str]: """Run a command inside a rendered project; capture output for assertions.""" # A rendered project's own tooling (`just ci`, `uv run ...`) must not inherit the # maintainer's interpreter pins, or uv rebuilds its venv against the wrong Python. @@ -149,5 +151,5 @@ def run_in(project: Path, *args: str, check: bool = True) -> subprocess.Complete check=check, capture_output=True, text=True, - env=_clean_env(), + env=_clean_env() | (env or {}), ) diff --git a/tests/test_generation.py b/tests/test_generation.py index 4d0a3f7..af32c9e 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -15,6 +15,7 @@ from tests.conftest import RenderFn, git_global_config, run_in if TYPE_CHECKING: + import subprocess from pathlib import Path @@ -57,6 +58,7 @@ class _PreCommit(TypedDict): "enable_dependency_audit": False, "enable_renovate": False, "enable_sha_pin_policy": False, + "enable_changelog": False, } FULL = { @@ -70,6 +72,7 @@ class _PreCommit(TypedDict): "enable_dependency_audit": True, "enable_renovate": True, "enable_sha_pin_policy": True, + "enable_changelog": True, } MATRIX = { @@ -721,6 +724,67 @@ def test_rendered_workflows_pass_zizmor(render: RenderFn, tmp_path: Path) -> Non ) +def test_changelog_layer(render: RenderFn, tmp_path: Path) -> None: + on = render({**MINIMAL, "enable_changelog": True}, tmp_path / "on") + assert "## [Unreleased]" in (on / "CHANGELOG.md").read_text() + workflow = (on / ".github" / "workflows" / "changelog.yml").read_text() + yaml.safe_load(workflow) # valid YAML after the multi-line run block + # `types:` replaces the defaults, so all five are needed for a label change to re-run it. + assert "types: [opened, synchronize, reopened, labeled, unlabeled]" in workflow + assert "push:" not in workflow + # The label is honoured in the step (never a job-level `if:` — that reports "skipped"). + assert "skip-changelog" in workflow + assert not re.search(r"^\s*if:", workflow, re.MULTILINE) + # zizmor: the base ref reaches the script via env, never inline in `run:`. + assert "BASE_REF: ${{ github.base_ref }}" in workflow + assert "timeout-minutes:" in workflow + assert "concurrency:" in workflow + assert "## Changelog" in (on / "AGENTS.md").read_text() + off = render(MINIMAL, tmp_path / "off") + assert not (off / "CHANGELOG.md").exists() + assert not (off / ".github" / "workflows" / "changelog.yml").exists() + assert "CHANGELOG" not in (off / "AGENTS.md").read_text() + + +def test_changelog_check_blocks_and_passes(render: RenderFn, tmp_path: Path) -> None: + """The shipped changelog.yml script blocks a src change with no entry, passes otherwise. + + Runs the workflow's `run:` block against a clone whose `origin/main` plays the PR base, + the same ref a full-history checkout gives CI — it proves the diff logic, not just that + the file rendered. + """ + project = render({**MINIMAL, "enable_changelog": True}, tmp_path / "out") + workflow = cast( + "_CiWorkflow", + yaml.safe_load((project / ".github" / "workflows" / "changelog.yml").read_text()), + ) + script = next(s["run"] for s in workflow["jobs"]["changelog"]["steps"] if "run" in s) + with git_global_config(tmp_path / "gitconfig"): + _ = run_in(project, "git", "add", "-A") + _ = run_in(project, "git", "commit", "-q", "-m", "init") + _ = run_in(project, "git", "branch", "-M", "main") + clone = tmp_path / "clone" + _ = run_in(tmp_path, "git", "clone", "-q", str(project), str(clone)) + _ = run_in(clone, "git", "switch", "-q", "-c", "feature") + _ = (clone / "src" / "demo_project" / "extra.py").write_text("X = 1\n") + _ = run_in(clone, "git", "add", "-A") + _ = run_in(clone, "git", "commit", "-q", "-m", "feat") + + def check(*, skip: bool) -> subprocess.CompletedProcess[str]: + env = {"BASE_REF": "main", "SKIP": "true" if skip else "false"} + return run_in(clone, "bash", "-e", "-o", "pipefail", "-c", script, check=False, env=env) + + blocked = check(skip=False) + assert blocked.returncode != 0 + assert "CHANGELOG.md" in blocked.stdout + assert check(skip=True).returncode == 0 + changelog = clone / "CHANGELOG.md" + _ = changelog.write_text(changelog.read_text() + "\n- Add `extra`.\n") + _ = run_in(clone, "git", "add", "-A") + _ = run_in(clone, "git", "commit", "-q", "-m", "docs") + assert check(skip=False).returncode == 0 + + def test_apache_license_renders(render: RenderFn, tmp_path: Path) -> None: project = render({**MINIMAL, "license": "Apache-2.0"}, tmp_path / "out") text = (project / "LICENSE").read_text() From c9d9e3454c1804122dfeadb0c488b97c16c7f200 Mon Sep 17 00:00:00 2001 From: Ashlen Date: Thu, 10 Sep 2026 13:26:55 -0600 Subject: [PATCH 2/5] ci: dogfood the changelog check on the maintainer repo Same workflow as the template layer, on the maintainer's own pins, with the trigger paths template/ and copier.yml: the CHANGELOG describes generated-project behaviour only, so maintainer CI changes (including Renovate's Action bumps) need no entry. Documents the layer in AGENTS.md and marks Phase 2 of the dogfooding audit as started. --- AGENTS.md | 12 +++++++++--- .../plans/2026-07-01-dogfood-gap-audit.md | 2 +- justfile | 3 ++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3401835..29f9561 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ 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. @@ -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 also changing `CHANGELOG.md` (add an entry under `## [Unreleased]`), 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). Three 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`, shipped in v0.3.0), then dogfooded here in the same PR. 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 @@ -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 @@ -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") guarantees every template-touching PR already added its entry. Breaking renames/moves need a version-gated `_migrations` entry. diff --git a/docs/superpowers/plans/2026-07-01-dogfood-gap-audit.md b/docs/superpowers/plans/2026-07-01-dogfood-gap-audit.md index 56ccf04..b52cf50 100644 --- a/docs/superpowers/plans/2026-07-01-dogfood-gap-audit.md +++ b/docs/superpowers/plans/2026-07-01-dogfood-gap-audit.md @@ -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 (`feat/changelog-check`, v0.3.0). Phase 3 not started. ## 1. Executive summary diff --git a/justfile b/justfile index b01a8ae..08916d1 100644 --- a/justfile +++ b/justfile @@ -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" From af681e2be256dd1710cd199d2184d7052af930dd Mon Sep 17 00:00:00 2001 From: Ashlen Date: Thu, 10 Sep 2026 14:30:59 -0600 Subject: [PATCH 3/5] fix(template): keep the changelog layer honest in nested repos With in_existing_repo the .github/ directory is never rendered, so the AGENTS.md section and the justfile ci comment must not cite a changelog.yml that does not exist. The authoring rule and CHANGELOG.md itself still ship; only the CI references are conditional. The same condition now also guards the comment's scan.yml reference, which had the same dangling problem. --- template/AGENTS.md.jinja | 2 +- template/justfile.jinja | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/template/AGENTS.md.jinja b/template/AGENTS.md.jinja index 01f2d66..6cce6cd 100644 --- a/template/AGENTS.md.jinja +++ b/template/AGENTS.md.jinja @@ -45,7 +45,7 @@ Put it in `tests/property/`. It MUST set `pytestmark = pytest.mark.property` and {% if enable_changelog %} ## Changelog -A PR that changes `src/` or `pyproject.toml` must add an entry under `## [Unreleased]` in `CHANGELOG.md` (CI: `changelog.yml`), or carry the `skip-changelog` label (create it once: `gh label create skip-changelog`). Promote the entries under a version heading in each release commit. +A PR that changes `src/` or `pyproject.toml` must add an entry under `## [Unreleased]` in `CHANGELOG.md`{% if not in_existing_repo %} (CI: `changelog.yml`), or carry the `skip-changelog` label (create it once: `gh label create skip-changelog`){% endif %}. Promote the entries under a version heading in each release commit. {% endif %}{% if enable_policy_tests %} ## Meta-guardrail diff --git a/template/justfile.jinja b/template/justfile.jinja index 971e66c..39c6f66 100644 --- a/template/justfile.jinja +++ b/template/justfile.jinja @@ -3,7 +3,7 @@ set shell := ["bash", "-eu", "-o", "pipefail", "-c"] default: @just --list -# The complete local gate — every PR-blocking check reproducible locally{% if enable_scanners or enable_changelog %} (CI only:{% if enable_scanners %} scanners, see scan.yml{% endif %}{% if enable_scanners and enable_changelog %};{% endif %}{% if enable_changelog %} the changelog check, see changelog.yml{% endif %}){% endif %}. +# The complete local gate — every PR-blocking check reproducible locally{% if not in_existing_repo and (enable_scanners or enable_changelog) %} (CI only:{% if enable_scanners %} scanners, see scan.yml{% endif %}{% if enable_scanners and enable_changelog %};{% endif %}{% if enable_changelog %} the changelog check, see changelog.yml{% endif %}){% endif %}. ci: fmt-check lint typecheck test{% if enable_property_tests %} fuzz{% endif %}{% if enable_policy_tests %} policy{% endif %}{% if enable_dependency_audit %} audit{% endif %} @echo "ci: all gates passed" From 2e57723a14ff7045fc9653c6239cf3b96501251b Mon Sep 17 00:00:00 2001 From: Ashlen Date: Thu, 10 Sep 2026 14:30:59 -0600 Subject: [PATCH 4/5] fix(changelog): require a real entry and fail closed on a bad base A deleted or whitespace-only CHANGELOG.md satisfied the check, because it only tested that the path appeared in the diff. It now requires at least one non-empty added line. An unresolvable base ref (a shallow checkout) made both git diff calls fail inside the test condition, which bash -e ignores, so the check printed ok and exited 0; the base is now verified first and the step errors. The gate test covers the whitespace touch, the deletion, the bad base, and a change outside the trigger paths (pinning the pathspec). The policy suite pins the maintainer copy's literals, since the generation suite only runs the rendered template's script. The nested-repo test asserts nothing rendered cites the omitted workflow, and the all-toggles renders include the new layer. --- .github/workflows/changelog.yml | 9 +++- CHANGELOG.md | 2 +- README.md | 2 +- ...changelog %}changelog.yml{% endif %}.jinja | 9 +++- tests/policy/test_gates.py | 17 ++++++ tests/test_generation.py | 53 ++++++++++++++----- 6 files changed, 74 insertions(+), 18 deletions(-) diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 4887c79..4da4407 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -37,9 +37,14 @@ jobs: 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 --name-only "$base...HEAD" -- CHANGELOG.md)" ]; then - echo "::error::template/ or copier.yml changed without a CHANGELOG.md entry; add one under [Unreleased] or label the PR skip-changelog" + && [ -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" diff --git a/CHANGELOG.md b/CHANGELOG.md index ab2bbcb..9de3108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 - `CHANGELOG.md` change; the `skip-changelog` label bypasses it. CI-only, no recipe. + new `CHANGELOG.md` line; the `skip-changelog` label bypasses it. CI-only, no recipe. ## [0.2.0] - 2026-09-02 diff --git a/README.md b/README.md index cb11caa..02b0ba6 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ 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 touch `CHANGELOG.md`, or carry the `skip-changelog` label | +| `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), `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). diff --git a/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_changelog %}changelog.yml{% endif %}.jinja b/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_changelog %}changelog.yml{% endif %}.jinja index 333b269..117968b 100644 --- a/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_changelog %}changelog.yml{% endif %}.jinja +++ b/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_changelog %}changelog.yml{% endif %}.jinja @@ -37,9 +37,14 @@ jobs: 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" -- src pyproject.toml)" ] \ - && [ -z "$(git diff --name-only "$base...HEAD" -- CHANGELOG.md)" ]; then - echo "::error::src/ or pyproject.toml changed without a CHANGELOG.md entry; add one under [Unreleased] or label the PR skip-changelog" + && [ -z "$(git diff -U0 "$base...HEAD" -- CHANGELOG.md | grep '^+[^+]')" ]; then + echo "::error::src/ or pyproject.toml changed without a new CHANGELOG.md line; add an entry under [Unreleased] or label the PR skip-changelog" exit 1 fi echo "changelog: ok" diff --git a/tests/policy/test_gates.py b/tests/policy/test_gates.py index 2fd1d1e..53b1d8c 100644 --- a/tests/policy/test_gates.py +++ b/tests/policy/test_gates.py @@ -49,6 +49,23 @@ def test_gitleaks_pin_matches_template() -> None: assert f"gitleaks (`{ours.group(1)}`)" in (ROOT / "AGENTS.md").read_text() +def test_changelog_check_literals() -> None: + """The maintainer's changelog.yml keeps the mechanics the generation suite proves. + + The suite runs the rendered template's script; this copy is hand-written with its own + pathspec, so its load-bearing literals are pinned here. + """ + workflow = (ROOT / ".github" / "workflows" / "changelog.yml").read_text() + assert "types: [opened, synchronize, reopened, labeled, unlabeled]" in workflow + assert "push:" not in workflow + assert "BASE_REF: ${{ github.base_ref }}" in workflow + assert "contains(github.event.pull_request.labels.*.name, 'skip-changelog')" in workflow + assert not re.search(r"^\s*if:", workflow, re.MULTILINE) + assert 'git rev-parse --verify --quiet "$base"' in workflow + assert '-- template copier.yml)" ]' in workflow + assert "-- CHANGELOG.md | grep '^+[^+]')\" ]" in workflow + + def test_actions_are_sha_pinned() -> None: """Every third-party `uses:` is a 40-char SHA + a v-prefixed version comment.""" # SHA + a version comment (v[.minor[.patch]]); our pins carry the action's diff --git a/tests/test_generation.py b/tests/test_generation.py index af32c9e..35f4afb 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -349,6 +349,7 @@ def test_existing_repo_layer(render: RenderFn, tmp_path: Path) -> None: { **MINIMAL, "enable_renovate": True, + "enable_changelog": True, "enable_precommit_install": True, "in_existing_repo": True, }, @@ -358,6 +359,11 @@ def test_existing_repo_layer(render: RenderFn, tmp_path: Path) -> None: assert not (parent / ".git" / "hooks" / "pre-commit").exists() for omitted in (".github", ".pre-commit-config.yaml", "renovate.json"): assert not (sub / omitted).exists() + # The changelog itself is per-project and ships; only its root-only workflow is omitted, + # and nothing rendered may cite a CI file that does not exist. + assert (sub / "CHANGELOG.md").is_file() + assert "changelog.yml" not in (sub / "AGENTS.md").read_text() + assert "changelog.yml" not in (sub / "justfile").read_text() assert (sub / "uv.lock").is_file() # The rendered project's own gate is still green from a subdirectory. _ = run_in(sub, "just", "ci") @@ -765,24 +771,46 @@ def test_changelog_check_blocks_and_passes(render: RenderFn, tmp_path: Path) -> _ = run_in(project, "git", "branch", "-M", "main") clone = tmp_path / "clone" _ = run_in(tmp_path, "git", "clone", "-q", str(project), str(clone)) - _ = run_in(clone, "git", "switch", "-q", "-c", "feature") - _ = (clone / "src" / "demo_project" / "extra.py").write_text("X = 1\n") - _ = run_in(clone, "git", "add", "-A") - _ = run_in(clone, "git", "commit", "-q", "-m", "feat") + changelog = clone / "CHANGELOG.md" + + def commit_all(message: str) -> None: + _ = run_in(clone, "git", "add", "-A") + _ = run_in(clone, "git", "commit", "-q", "-m", message) - def check(*, skip: bool) -> subprocess.CompletedProcess[str]: - env = {"BASE_REF": "main", "SKIP": "true" if skip else "false"} + def check(*, skip: bool = False, base: str = "main") -> subprocess.CompletedProcess[str]: + env = {"BASE_REF": base, "SKIP": "true" if skip else "false"} return run_in(clone, "bash", "-e", "-o", "pipefail", "-c", script, check=False, env=env) - blocked = check(skip=False) + # A src change with no entry is blocked; the label or a real entry unblocks it. + _ = run_in(clone, "git", "switch", "-q", "-c", "feature") + _ = (clone / "src" / "demo_project" / "extra.py").write_text("X = 1\n") + commit_all("feat") + blocked = check() assert blocked.returncode != 0 assert "CHANGELOG.md" in blocked.stdout assert check(skip=True).returncode == 0 - changelog = clone / "CHANGELOG.md" - _ = changelog.write_text(changelog.read_text() + "\n- Add `extra`.\n") - _ = run_in(clone, "git", "add", "-A") - _ = run_in(clone, "git", "commit", "-q", "-m", "docs") - assert check(skip=False).returncode == 0 + # Fail closed: an unresolvable base errors instead of passing vacuously. + unresolvable = check(base="nonexistent") + assert unresolvable.returncode != 0 + assert "cannot resolve" in unresolvable.stdout + # A whitespace-only touch is not an entry; a real line is. + _ = changelog.write_text(changelog.read_text() + "\n") + commit_all("touch") + assert check().returncode != 0 + _ = changelog.write_text(changelog.read_text() + "- Add `extra`.\n") + commit_all("docs") + assert check().returncode == 0 + # Deleting the changelog is not an entry either. + _ = run_in(clone, "git", "switch", "-q", "-c", "deleter", "main") + _ = (clone / "src" / "demo_project" / "extra.py").write_text("X = 2\n") + _ = run_in(clone, "git", "rm", "-q", "CHANGELOG.md") + commit_all("rm") + assert check().returncode != 0 + # Changes outside src/ and pyproject.toml need no entry (pins the pathspec). + _ = run_in(clone, "git", "switch", "-q", "-c", "docs-only", "main") + _ = (clone / "README.md").write_text("# Demo\n") + commit_all("readme") + assert check().returncode == 0 def test_apache_license_renders(render: RenderFn, tmp_path: Path) -> None: @@ -842,6 +870,7 @@ def test_all_toggles_on_passes_full_gate(render: RenderFn, tmp_path: Path) -> No "enable_dependency_audit": True, "enable_renovate": True, "enable_sha_pin_policy": True, + "enable_changelog": True, } project = render(full, tmp_path / "out") From f57560bc4955d0d0acef89447bd8adddae4e6297 Mon Sep 17 00:00:00 2001 From: Ashlen Date: Thu, 10 Sep 2026 14:30:59 -0600 Subject: [PATCH 5/5] docs(agents): drop the unevidenced v0.3.0 and correct the check's guarantees No v0.3.0 tag exists yet; the entry is still under Unreleased. The Release section claimed the check guarantees an entry, but the skip-changelog label bypasses it, so promotion needs a look at labelled PRs. Also records that the changelog workflow is the one exception to folding maintainer CI into test-template.yml, and describes the fail-closed base check. --- AGENTS.md | 10 +++++----- docs/superpowers/plans/2026-07-01-dogfood-gap-audit.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 29f9561..ce5e4fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ just ci # fmt-check + lint + typecheck + test + policy + audit, then "ci: all 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 %}`). @@ -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.) @@ -90,9 +90,9 @@ Deliberate divergences from the template's `renovate.json`: the `pre-commit` man ## Changelog check -`.github/workflows/changelog.yml` fails a PR that changes `template/**` or `copier.yml` without also changing `CHANGELOG.md` (add an entry under `## [Unreleased]`), 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). Three 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. +`.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`, shipped in v0.3.0), then dogfooded here in the same PR. 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"). +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`) @@ -127,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; the changelog check (see "Changelog check") guarantees every template-touching PR already added its entry. +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. diff --git a/docs/superpowers/plans/2026-07-01-dogfood-gap-audit.md b/docs/superpowers/plans/2026-07-01-dogfood-gap-audit.md index b52cf50..e7657d0 100644 --- a/docs/superpowers/plans/2026-07-01-dogfood-gap-audit.md +++ b/docs/superpowers/plans/2026-07-01-dogfood-gap-audit.md @@ -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"). **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 (`feat/changelog-check`, v0.3.0). Phase 3 not started. +> **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