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
33 changes: 33 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!--
Thanks for opening a PR! This template auto-fills the body of new PRs.
Replace the placeholder text below; remove sections that don't apply.
Dependabot bypasses this template (it supplies its own body); see
`.github/workflows/dependabot-changelog.yml` for how Dependabot PRs
get a CHANGELOG entry automatically.
-->

## Summary

<!-- Two or three sentences on what changed and why. -->

## Test plan

<!-- Checklist the maintainer can walk to verify the change. -->

- [ ] `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

<!--
Confirm the matching CHANGELOG.md entry under `## [Unreleased]`
(per CLAUDE.md § "Adding a CHANGELOG entry on every PR").
Categories: Added / Changed / Fixed.
-->

- [ ] Added a `## [Unreleased]` entry to `CHANGELOG.md` under the appropriate Keep-a-Changelog category (Added / Changed / Fixed)

Closes #
216 changes: 216 additions & 0 deletions .github/workflows/dependabot-changelog.yml
Original file line number Diff line number Diff line change
@@ -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> 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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`.
Expand Down
Loading