diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..62055ed --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,33 @@ + + +## Summary + + + +## Test plan + + + +- [ ] `uv run pytest tests//test_.py::test_` — passes +- [ ] `uv run ruff check src/ tests/` — clean +- [ ] `uv run ruff format --check src/ tests/` — clean +- [ ] `uv run mypy src/` — clean +- [ ] Confirm no regression in the affected module + +## CHANGELOG + + + +- [ ] Added a `## Unreleased` entry to `CHANGELOG.md` + +Closes # diff --git a/.github/workflows/dependabot-changelog.yml b/.github/workflows/dependabot-changelog.yml new file mode 100644 index 0000000..e5d6dda --- /dev/null +++ b/.github/workflows/dependabot-changelog.yml @@ -0,0 +1,200 @@ +name: Dependabot CHANGELOG + +# Auto-appends a CHANGELOG entry to Dependabot-authored PRs so they +# satisfy the project rule documented in CLAUDE.md § "Adding a +# CHANGELOG entry on every PR" without manual intervention. +# +# Triggered on pull_request_target so the workflow runs in the base +# branch's context with write permissions (Dependabot's pull_request +# event runs with read-only GITHUB_TOKEN). +# +# IMPORTANT: pushes back via a GitHub App installation token rather +# than secrets.GITHUB_TOKEN. Pushes authenticated with GITHUB_TOKEN +# do NOT trigger downstream `pull_request` workflows (GitHub's +# anti-loop policy), which would leave required status checks +# (lint, typecheck, test, version-sync) unsatisfied on the bot's +# follow-up commit and block merge under the repo's main-branch +# ruleset. App-token-authored pushes do trigger those workflows +# normally. +# +# Required repo secrets (operator must configure once): +# BOT_APP_ID — GitHub App ID (numeric) +# BOT_APP_PRIVATE_KEY — PEM contents of the App's private key +# +# Loop guard: skips if the latest commit on the head branch was +# authored by the bot itself — i.e. our own previous run. +# +# Idempotency: skips if a CHANGELOG entry referencing the PR number +# already exists. + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + changelog: + if: github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - name: Mint GitHub App installation token + id: app-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + + - name: Checkout PR branch + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + token: ${{ steps.app-token.outputs.token }} + fetch-depth: 2 + + - name: Loop guard — skip if last commit was ours + id: guard + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + LAST_AUTHOR=$(git log -1 --pretty=%an) + echo "last_author=$LAST_AUTHOR" >> "$GITHUB_OUTPUT" + if [ "$LAST_AUTHOR" = "cmeans-claude-dev[bot]" ]; then + echo "Last commit authored by the bot; skipping to avoid loop." + echo "skip=true" >> "$GITHUB_OUTPUT" + elif grep -qE "\(#${PR_NUMBER}\)" CHANGELOG.md; then + echo "CHANGELOG.md already references (#${PR_NUMBER}); skipping." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Fetch Dependabot metadata + if: steps.guard.outputs.skip != 'true' + id: meta + uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0 + with: + github-token: ${{ steps.app-token.outputs.token }} + + - name: Compose and prepend CHANGELOG entry + if: steps.guard.outputs.skip != 'true' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + DEPS_JSON: ${{ steps.meta.outputs.updated-dependencies-json }} + DEPENDENCY_GROUP: ${{ steps.meta.outputs.dependency-group }} + ECOSYSTEM: ${{ steps.meta.outputs.package-ecosystem }} + UPDATE_TYPE: ${{ steps.meta.outputs.update-type }} + run: | + python3 - <<'PY' + import json + import os + import pathlib + + pr_number = os.environ["PR_NUMBER"] + deps_json = os.environ.get("DEPS_JSON") or "[]" + # Prefer the named Dependabot group (matches PR title: + # "Bump the group across N directories with M updates"). + # Fall back to the ecosystem identifier (pip / github-actions / ...). + group = (os.environ.get("DEPENDENCY_GROUP") or "").strip() + ecosystem = (os.environ.get("ECOSYSTEM") or "deps").strip() + label = group if group else ecosystem + update_type = os.environ.get("UPDATE_TYPE") or "version-update" + + deps = json.loads(deps_json) + if not deps: + # Nothing to record — exit cleanly. + print("No dependencies in metadata; skipping.") + raise SystemExit(0) + + parts = [ + f"{d['dependencyName']} {d.get('prevVersion', '?')}→{d.get('newVersion', '?')}" + for d in deps + ] + summary = ", ".join(parts) + + # Severity hint from update-type — security updates are the + # interesting category at release time. + tag = "" + if "security" in update_type: + tag = " — picks up upstream security advisory; see PR body for CVE links." + + entry = ( + f"- **Bump {label} group: {summary}** (#{pr_number}){tag}\n" + ) + + path = pathlib.Path("CHANGELOG.md") + text = path.read_text() + lines = text.splitlines(keepends=True) + + # Locate `## Unreleased` block and the `### Changed` subsection + # within it. If `### Changed` is absent, create it right after + # `## Unreleased`. + unreleased_idx = None + for i, line in enumerate(lines): + if line.strip() == "## Unreleased": + unreleased_idx = i + break + if unreleased_idx is None: + # Insert a fresh `## Unreleased` section after the title. + insert_at = 0 + for i, line in enumerate(lines): + if line.startswith("# "): + insert_at = i + 1 + break + new_block = ["\n", "## Unreleased\n", "\n", "### Changed\n", "\n", entry] + lines = lines[:insert_at] + new_block + lines[insert_at:] + else: + # Search for `### Changed` between Unreleased and next `## ` heading. + changed_idx = None + end_idx = len(lines) + for j in range(unreleased_idx + 1, len(lines)): + if lines[j].startswith("## "): + end_idx = j + break + if lines[j].strip() == "### Changed": + changed_idx = j + break + if changed_idx is not None: + # Insert entry directly after the `### Changed` header + # (after the blank line that follows it, if present). + insert_at = changed_idx + 1 + if insert_at < end_idx and lines[insert_at].strip() == "": + insert_at += 1 + lines.insert(insert_at, entry) + else: + # `### Changed` doesn't exist within Unreleased — create it. + insert_at = unreleased_idx + 1 + if insert_at < end_idx and lines[insert_at].strip() == "": + insert_at += 1 + block = ["### Changed\n", "\n", entry, "\n"] + for k, ln in enumerate(block): + lines.insert(insert_at + k, ln) + + path.write_text("".join(lines)) + print(f"Inserted CHANGELOG entry: {entry.strip()}") + PY + + - name: Commit and push (via App token so CI re-fires) + if: steps.guard.outputs.skip != 'true' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + GH_BOT_USER_ID: '272174644' + run: | + if git diff --quiet CHANGELOG.md; then + echo "No changes to commit." + exit 0 + fi + # Commit author/committer aligns with the App's bot account so + # commits are attributed to cmeans-claude-dev[bot]. The numeric + # user id is required for GitHub's noreply-email format to + # resolve back to the bot account; otherwise commits show up + # under the App ID rather than the bot user. + git config user.name "cmeans-claude-dev[bot]" + git config user.email "${GH_BOT_USER_ID}+cmeans-claude-dev[bot]@users.noreply.github.com" + git add CHANGELOG.md + git commit -m "chore(changelog): record dep bumps from #${PR_NUMBER}" + git push diff --git a/CHANGELOG.md b/CHANGELOG.md index af79069..cd20acc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- **Dependabot PR hygiene: `.github/PULL_REQUEST_TEMPLATE.md` + auto-CHANGELOG workflow** (#58) — addresses the QA gap surfaced by the first Dependabot PR (#55) where the Dependabot-generated body had no `## QA` section and the auto-bump didn't produce the unconditional CHANGELOG entry that `CLAUDE.md` § "Adding a CHANGELOG entry on every PR" requires. Two artifacts: (1) `PULL_REQUEST_TEMPLATE.md` providing a `## Summary` / `## Test plan` / `## CHANGELOG` scaffold for human-authored PRs (Dependabot bypasses templates). (2) `.github/workflows/dependabot-changelog.yml` running on `pull_request_target` for `dependabot[bot]`-authored PRs only — mints a token via `actions/create-github-app-token` (SHA-pinned to v3.1.1) so pushes attribute to `cmeans-claude-dev[bot]` and re-fire the required `pull_request` checks (lint / typecheck / test 3.11/3.12/3.13 / version-sync), enumerates the bump set via `dependabot/fetch-metadata` (SHA-pinned to v2.5.0), prefers the named `dependency-group` output and falls back to `package-ecosystem`, and pushes a follow-up commit (`chore(changelog): record dep bumps from #N`) back to the Dependabot branch. Required repo secrets: `BOT_APP_ID`, `BOT_APP_PRIVATE_KEY`. Loop guard skips when the last commit author is `cmeans-claude-dev[bot]`; idempotency check skips when `(#N)` is already present in `CHANGELOG.md` so `@dependabot recreate`/`rebase` doesn't double-write and a hand-prepended human entry (e.g., the CVE callouts in PR #55) is preserved. + ### Fixed - **Harden `pr-labels-ci.yml` against shell injection via fork-PR branch names** (#53) — closes #52. Cascades the `env:` pattern from `cmeans/mcp-clipboard#88` into this repo's `.github/workflows/pr-labels-ci.yml`. Both the `on-ci-pass` and `on-ci-fail` jobs previously inlined `${{ github.event.workflow_run.head_branch }}` directly inside `run:` blocks. `head_branch` is contributor-controlled on fork PRs and git refnames allow shell metacharacters (`$`, backtick, `;`, `&`, `|`, etc.), so a malicious fork branch name would render as directly-executed shell once the expression was substituted. `REPO`, `RUN_ID`, and `HEAD_BRANCH` now come through step-level `env:` blocks and the shell references them as `$REPO` / `$RUN_ID` / `$HEAD_BRANCH`. Also avoids the latent parser trap documented in `cmeans/yt-dont-recommend#28`: GHA substitutes `${{ ... }}` inside `run:` blocks before the shell sees them *including within shell comments*, and the queue-time parser rejects an empty expression on `workflow_dispatch` — the explanatory comments therefore describe the concept ("not a direct GHA expression") rather than showing the literal sequence. Verified locally that `yaml.safe_load` parses cleanly and that no `${{ ... }}` substitution survives inside any `run:` body.