diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..9b02d52 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,33 @@ + + +## Summary + + + +## Test plan + + + +- [ ] `uv run pytest` — passes +- [ ] `uv run ruff check src/ tests/` — clean +- [ ] `uv run ruff format --check src/ tests/` — clean +- [ ] `uv run mypy src/mcp_clipboard/` — clean +- [ ] Confirm no regression in the affected module + +## CHANGELOG + + + +- [ ] Added a `## [Unreleased]` entry to `CHANGELOG.md` under the appropriate Keep-a-Changelog category (Added / Changed / Fixed) + +Closes # diff --git a/.github/workflows/dependabot-changelog.yml b/.github/workflows/dependabot-changelog.yml new file mode 100644 index 0000000..1412e43 --- /dev/null +++ b/.github/workflows/dependabot-changelog.yml @@ -0,0 +1,216 @@ +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). On repos with required status checks, that +# leaves the bot's follow-up commit failing the main-branch ruleset +# even though the workflow succeeded. App-token-authored pushes do +# trigger those workflows normally. mcp-clipboard's main ruleset +# does not currently require status checks, but we still use the +# App token so commits attribute to `cmeans-claude-dev[bot]` and so +# the workflow stays portable to any future ruleset change. +# +# 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + 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@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.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 the `## [Unreleased]` block (with or without + # Keep-a-Changelog brackets) and the `### Changed` subsection + # within it. If `### Changed` is absent, create it at the + # right Keep-a-Changelog v1.1.0 position. + unreleased_idx = None + unreleased_heading = "## [Unreleased]" + for i, line in enumerate(lines): + stripped = line.strip() + if stripped == "## Unreleased" or stripped == "## [Unreleased]": + unreleased_idx = i + unreleased_heading = stripped + 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", f"{unreleased_heading}\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 + # at the right position per Keep-a-Changelog v1.1.0 ordering: + # Added → Changed → Deprecated → Removed → Fixed → Security. + # Walk forward from `## [Unreleased]` to find either the first + # subsection that should sort AFTER `### Changed`, or the next + # `## ` release heading; insert immediately before whichever + # comes first. Default insertion point is the end of the + # Unreleased section (just before the next `## ` heading). + after_changed = {"### Deprecated", "### Removed", "### Fixed", "### Security"} + insert_at = end_idx + for j in range(unreleased_idx + 1, end_idx): + if lines[j].strip() in after_changed: + insert_at = j + break + 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 a1d56d8..ccf1948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to this project will be documented here. ## [Unreleased] ### Added +- `.github/PULL_REQUEST_TEMPLATE.md` auto-fills new human-authored + PR bodies with Summary, Test plan (matching the CI's `pytest`, + `ruff check`, `ruff format --check`, `mypy` invocations), and + CHANGELOG-confirmation sections. Dependabot bypasses the template + and is handled by the auto-CHANGELOG workflow instead. +- `.github/workflows/dependabot-changelog.yml` auto-prepends a + `## [Unreleased]` → `### Changed` entry to Dependabot-authored PRs + so they satisfy the per-PR CHANGELOG rule without manual + intervention. Runs on `pull_request_target`, filters to + `dependabot[bot]`, mints a GitHub App installation token via + `actions/create-github-app-token`, fetches metadata via + `dependabot/fetch-metadata@v3.1.0` (the v3 line fixed empty + `prevVersion`/`newVersion` on grouped PRs), and pushes the + CHANGELOG commit under the `cmeans-claude-dev[bot]` identity. + Subsection insertion respects Keep-a-Changelog v1.1.0 ordering + (Added → Changed → Deprecated → Removed → Fixed → Security) so a + newly-created `### Changed` block lands in the right position. + Loop guard skips when the last commit is already by the bot; + idempotency guard skips when the PR number is already referenced + in `CHANGELOG.md`. Operator must configure two repo secrets + (`BOT_APP_ID`, `BOT_APP_PRIVATE_KEY`) before the workflow can run. +- `CLAUDE.md § Conventions` documents the per-PR CHANGELOG rule and + the Keep-a-Changelog category set, mirroring the conventions + already in place across `cmeans/mcp-synology` and + `cmeans/pypi-winnow-downloads`. - Dependabot version-update configuration (`.github/dependabot.yml`) for pip and github-actions ecosystems. Weekly schedule (Monday 06:00 America/Chicago), grouped per ecosystem to reduce noise. diff --git a/CLAUDE.md b/CLAUDE.md index 07e6bd5..489a91c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,21 @@ Tests use `pytest` with `pytest-asyncio` (async mode: auto). All pytest config i - Linux with Wayland is tested on real hardware; X11 has unit tests but is unverified on live hardware; macOS and Windows are complete but untested - `clipboard_paste` intentionally has no return type annotation — adding `-> str | Image` causes FastMCP to fail Pydantic schema generation for `Image` +## Conventions + +### Adding a CHANGELOG entry on every PR + +Every PR — features, fixes, infra, tests, docs — adds an entry to `CHANGELOG.md` under the `## [Unreleased]` section at the top of the file. Do not defer CHANGELOG updates until release prep. + +`CHANGELOG.md` follows **[Keep a Changelog](https://keepachangelog.com/) categories**: +- `### Added` — anything new: features, capabilities, tests, docs, dev tooling +- `### Changed` — behavior or API changes that aren't bug fixes +- `### Fixed` — bug fixes + +Reference the PR number and any closed issue: `- ... (#16) — closes #14`. If no `## [Unreleased]` section exists (because the previous release just shipped), add one above the latest version section. + +Dependabot PRs are exempt from manual entry — `.github/workflows/dependabot-changelog.yml` auto-prepends an entry under `### Changed` (or creates the subsection at the right Keep-a-Changelog position if it does not exist). The workflow needs `BOT_APP_ID` and `BOT_APP_PRIVATE_KEY` repo secrets to mint a GitHub App installation token; without those secrets the workflow fails fast on the App-token mint step. + ## Packaging Feature Branches - **`feature/homebrew-tap`** (local only) — Homebrew formula, update script, and CI template for a `cmeans/homebrew-mcp-clipboard` tap. Formula resource stanzas need populating via `brew update-python-resources` on macOS before the tap can be published. Has 36 transitive dependencies from `mcp[cli]`.