From 023e91b5134ef62e318b126cf7dced3e928118ce Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 23 Jun 2026 11:30:53 -0400 Subject: [PATCH 01/12] feat(release): automate versioning and releases with changesets --- .changeset/README.md | 41 ++++++++ .changeset/config.json | 12 +++ .github/contributing.md | 1 + .github/maintainers_guide.md | 33 ++++++- .github/workflows/release.yml | 59 ++++++++++++ .gitignore | 3 + AGENTS.md | 25 +++++ Makefile | 5 +- scripts/changeset_version.sh | 13 +++ scripts/new_changeset.py | 84 ++++++++++++++++ scripts/seed_package_json.py | 52 ++++++++++ scripts/sync_plugin_versions.py | 55 +++++++++++ tests/unit/test_release_scripts.py | 149 +++++++++++++++++++++++++++++ 13 files changed, 527 insertions(+), 5 deletions(-) create mode 100644 .changeset/README.md create mode 100644 .changeset/config.json create mode 100644 .github/workflows/release.yml create mode 100755 scripts/changeset_version.sh create mode 100644 scripts/new_changeset.py create mode 100644 scripts/seed_package_json.py create mode 100644 scripts/sync_plugin_versions.py create mode 100644 tests/unit/test_release_scripts.py diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..c82509d --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,41 @@ +# Changesets + +This folder drives the release process. Each user-facing change ships with a +**changeset** — a small markdown file describing the change and how it bumps the +version. On merge to `main`, the release workflow consumes accumulated changesets, +opens a "Version Packages" PR, and (once that PR is merged) tags the release and +publishes a GitHub release. + +See [Versioning](../.github/maintainers_guide.md#versioning) for the full flow. + +## Adding a changeset + +You do **not** need Node.js installed. Either run the helper: + +```sh +make changeset +``` + +…or hand-write a file named `.changeset/.md` (the name is arbitrary; one +per PR is conventional) with this format: + +```md +--- +"slack": minor +--- + +Add the channel-digest command +``` + +- The frontmatter key is the package name, always `"slack"` (this repo ships a single + package). The value is the semver bump level: `patch`, `minor`, or `major`. +- The body becomes the changelog entry, so write it for a reader of the release notes: + **what** changed and, when relevant, **why**. + +## Choosing a bump level + +- `patch` — bug fixes and docs/internal changes that don't alter behavior for users. +- `minor` — new skills, commands, or capabilities (backwards compatible). +- `major` — breaking changes to existing behavior. + +A PR with no user-facing change (e.g. CI tweaks) needs no changeset. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..28dcd20 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "restricted", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [], + "privatePackages": { "version": true, "tag": true } +} diff --git a/.github/contributing.md b/.github/contributing.md index 40ae508..e429ff2 100644 --- a/.github/contributing.md +++ b/.github/contributing.md @@ -39,6 +39,7 @@ For your contribution to be accepted: - [x] The test suite must be complete and pass. - [x] The changes must be approved by code review. - [x] Commits should be atomic and messages must be descriptive. Related issues should be mentioned by Issue number. +- [x] User-facing changes include a changeset (run `make changeset`). See [`.changeset/README.md`](../.changeset/README.md) for the format; releases are automated from these. If the contribution doesn't meet the above criteria, you may fail our automated checks or a maintainer will discuss it with you. You can continue to improve a Pull Request by adding commits to the branch from which the PR was created. diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index 07a635b..c569936 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -50,10 +50,34 @@ Follow the [conventional commit specification][conv-commits]. PR titles and commit messages use prefixes like `feat:`, `fix:`, `chore:`, `docs:`, etc. First letter after the prefix is lowercase unless it's a proper noun. -### Plugin version bumps - -Every release must bump the `version` field in -`.claude-plugin/plugin.json` and `.cursor-plugin/plugin.json` following [semver][semver]. +### Releasing (changesets) + +Releases are automated with [changesets][changesets] via +`.github/workflows/release.yml`. There is **no manual version bump or tagging** — +both `.claude-plugin/plugin.json` and `.cursor-plugin/plugin.json` are bumped for you. +The repo stays Node-free on disk: Node runs only in the release workflow, and the +`package.json` changesets needs is generated on the fly (`scripts/seed_package_json.py`) +and gitignored. `.claude-plugin/plugin.json` is the version source of truth. + +**Contributors** add a changeset to any PR with a user-facing change — `make changeset` +or a hand-written `.changeset/.md` (format and bump-level guidance in +[`.changeset/README.md`](../.changeset/README.md)). + +**Maintainers** cut a release by merging PRs: + +1. Merging a feature PR to `main` causes the release workflow to open (or update) a + **"chore: release"** PR. It runs `changeset version`, which computes the next semver, + writes `CHANGELOG.md`, syncs the version into both plugin manifests + (`scripts/sync_plugin_versions.py`), and removes the consumed changesets. Review this + PR like any other — it's where you confirm the resulting version and changelog. +2. Merge the "chore: release" PR. With no changesets left, the workflow runs + `changeset publish`: the package is `private`, so npm is skipped, a `v` git + tag is created, and a GitHub release is published with notes drawn from `CHANGELOG.md`. + +**One-time setup:** enable **Settings → Actions → General → "Allow GitHub Actions to +create and approve pull requests"** so the action can open the release PR. (Note: PRs +opened by the default `GITHUB_TOKEN` don't trigger `ci-build.yml`; the release PR is +mechanical, so this is acceptable.) ## Everything Else @@ -83,3 +107,4 @@ Patch and minor updates are auto-approved and auto-merged via the [gh-cli]: https://cli.github.com [conv-commits]: https://www.conventionalcommits.org [semver]: https://semver.org +[changesets]: https://github.com/changesets/changesets diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c32a32a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,59 @@ +name: Release + +# Drives the changesets release flow. On every push to main: +# - If changesets are pending, open/update a "chore: release" PR that bumps the +# version (package.json + both plugin manifests) and writes CHANGELOG.md. +# - Once that PR is merged (no changesets remain), tag the release and publish a +# GitHub release. The package is private, so npm publish is skipped. +# See .changeset/README.md and .github/maintainers_guide.md for the full process. + +on: + push: + branches: + - main + workflow_dispatch: + +# Don't let two release runs race on the same branch. +concurrency: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: write # create commits, tags, and releases + pull-requests: write # open/update the version PR + +env: + SUPPORTED_PY: "3.14" + NODE_VERSION: "20" + CHANGESETS_CLI: "@changesets/cli@^2.27" + +jobs: + release: + name: Release + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + # Full history so changesets can resolve changed files; credentials are + # persisted (the action pushes the version commit and the release tag). + fetch-depth: 0 + - name: Set up Node ${{ env.NODE_VERSION }} + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Set up Python ${{ env.SUPPORTED_PY }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ env.SUPPORTED_PY }} + - name: Seed ephemeral package.json + # Generates a gitignored package.json from .claude-plugin/plugin.json so the + # Node-based changesets tooling has the root manifest it requires. + run: python scripts/seed_package_json.py + - name: Changesets release + uses: changesets/action@3841a0683d3cfa6dae0f9bb335290003010fe3f0 # v1.9.0 + with: + version: bash scripts/changeset_version.sh + publish: npx --yes ${{ env.CHANGESETS_CLI }} publish + commit: "chore: release" + title: "chore: release" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 94f6884..2a0bb2c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ node_modules/ package-lock.json *.tgz +# Ephemeral root manifest seeded by scripts/seed_package_json.py during release. +# plugin.json is the source of truth; this is never committed. +/package.json # Build dist/ diff --git a/AGENTS.md b/AGENTS.md index 5cb4ca0..03c6da3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ Requires Python 3.14+. Run `make install` before first use to set up the virtual | `make clean` | Remove .venv and .ollama | | `make cursor-install` | Install this plugin into a local Cursor for development | | `make cursor-uninstall` | Uninstall this plugin from the local Cursor install | +| `make changeset` | Create a changeset for the next release (see Releasing) | The LLM tests read two environment variables: `OLLAMA_MODEL_NAME` (the DeepEval judge model, defaults to `gemma4`) and `SLACK_MCP_TOKEN` (a Slack MCP bearer token; the MCP tool-selection test is skipped when it's unset). Copy `.env.example` to `.env` and fill in values — the `Makefile` auto-loads `.env` — or pass them inline, e.g. `OLLAMA_MODEL_NAME= make test-eval`. @@ -47,3 +48,27 @@ GitHub Actions (`.github/workflows/ci-build.yml`) gates every PR with: - **Test** — `make test-unit` (pytest) LLM-judged tests are not run in CI (Ollama + model download would exceed time budget). + +## Releasing + +Releases are driven by [changesets](https://github.com/changesets/changesets). The +runtime stays pure Python — Node only runs inside the release workflow, and no +`package.json` is committed (it's generated on the fly by `scripts/seed_package_json.py` +and gitignored). `.claude-plugin/plugin.json` is the version source of truth. + +**Per change:** every PR with a user-facing change adds a changeset. Run `make changeset` +(or hand-write a `.changeset/.md`); see `.changeset/README.md` for the format and +how to pick a bump level. + +**On merge to `main`** (`.github/workflows/release.yml`): + +1. If changesets are pending, the `changesets/action` opens/updates a **"chore: release"** + PR that runs `changeset version` — bumping `package.json`, syncing the version into + both `plugin.json` manifests (`scripts/sync_plugin_versions.py`), writing `CHANGELOG.md`, + and deleting the consumed changesets. +2. Merging that PR (no changesets left) triggers `changeset publish`, which — because the + package is `private` — skips npm, creates the `v` git tag, and publishes a + GitHub release with notes from `CHANGELOG.md`. + +A one-time repo setting is required: **Settings → Actions → "Allow GitHub Actions to +create and approve pull requests."** diff --git a/Makefile b/Makefile index 9faff48..ba12fb3 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ OLLAMA_MODEL := $(or $(OLLAMA_MODEL_NAME),gemma4) UNAME_S := $(shell uname -s) -TARGETS := help install install-test install-tools clean lint format test test-unit test-eval cursor-install cursor-uninstall +TARGETS := help install install-test install-tools clean lint format test test-unit test-eval cursor-install cursor-uninstall changeset .PHONY: $(TARGETS) @@ -73,6 +73,9 @@ cursor-install: $(VENV) ## Install this plugin into a local Cursor for developme cursor-uninstall: $(VENV) ## Uninstall this plugin from the local Cursor install $(PYTHON) scripts/cursor.py uninstall +changeset: ## Create a changeset describing a user-facing change (for the next release) + $(PYTHON) scripts/new_changeset.py + lint: ## Run ruff linter checks $(RUFF) check . diff --git a/scripts/changeset_version.sh b/scripts/changeset_version.sh new file mode 100755 index 0000000..5af640c --- /dev/null +++ b/scripts/changeset_version.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# The `version` command for changesets/action. +# +# `changeset version` consumes the accumulated changesets: it bumps the ephemeral +# package.json, writes CHANGELOG.md, and deletes the consumed changeset files. We +# then sync the new version into the two plugin manifests so the "Version Packages" +# PR carries the bump everywhere it matters. The action commits all of this. +set -euo pipefail + +CHANGESETS_CLI="@changesets/cli@^2.27" + +npx --yes "${CHANGESETS_CLI}" version +python scripts/sync_plugin_versions.py diff --git a/scripts/new_changeset.py b/scripts/new_changeset.py new file mode 100644 index 0000000..4d3e950 --- /dev/null +++ b/scripts/new_changeset.py @@ -0,0 +1,84 @@ +"""Scaffold a new changeset file so contributors never need Node.js. + +Writes ``.changeset/.md`` with the frontmatter changesets expects: + + --- + "slack": + --- + + + +Runs interactively (prompts for bump level + summary) or non-interactively via +``--bump`` / ``--summary`` flags. +""" + +import argparse +import logging +import random +from pathlib import Path + +logger = logging.getLogger(Path(__file__).stem) + +REPO_ROOT = Path(__file__).resolve().parent.parent +CHANGESET_DIR = REPO_ROOT / ".changeset" + +PACKAGE_NAME = "slack" +BUMP_LEVELS = ("patch", "minor", "major") + +# Used to build memorable, collision-resistant filenames (changesets convention). +ADJECTIVES = ( + "brave", "calm", "clever", "eager", "fancy", "gentle", "happy", "jolly", + "kind", "lively", "proud", "quick", "shiny", "smart", "swift", "witty", +) +ANIMALS = ( + "otters", "pandas", "foxes", "lions", "tigers", "whales", "eagles", + "owls", "bears", "wolves", "moose", "hawks", "seals", "rabbits", +) + + +def random_slug() -> str: + return f"{random.choice(ADJECTIVES)}-{random.choice(ANIMALS)}-{random.randint(100, 999)}" + + +def render(bump: str, summary: str) -> str: + return f'---\n"{PACKAGE_NAME}": {bump}\n---\n\n{summary.strip()}\n' + + +def prompt_bump() -> str: + options = ", ".join(BUMP_LEVELS) + while True: + choice = input(f"Bump level ({options}) [patch]: ").strip().lower() or "patch" + if choice in BUMP_LEVELS: + return choice + print(f"Please choose one of: {options}") + + +def write_changeset(bump: str, summary: str, directory: Path = CHANGESET_DIR) -> Path: + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{random_slug()}.md" + path.write_text(render(bump, summary)) + return path + + +def main() -> None: + parser = argparse.ArgumentParser( + prog=Path(__file__).name, + description="Create a changeset describing a user-facing change.", + ) + parser.add_argument("--bump", choices=BUMP_LEVELS, help="Semver bump level") + parser.add_argument("--summary", help="Changelog summary for the change") + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + + bump = args.bump or prompt_bump() + summary = args.summary or input("Summary: ").strip() + if not summary: + parser.error("a non-empty summary is required") + + path = write_changeset(bump, summary) + logger.info(f"Created {path.relative_to(REPO_ROOT)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/seed_package_json.py b/scripts/seed_package_json.py new file mode 100644 index 0000000..016b3d3 --- /dev/null +++ b/scripts/seed_package_json.py @@ -0,0 +1,52 @@ +"""Seed an ephemeral root ``package.json`` for the release workflow. + +Changesets (and the ``changesets/action``) are Node tools that require a +``package.json`` at the repo root to operate. We don't want a Node artifact +committed here, so the release workflow generates one on the fly, seeded from the +real source of truth: the ``version`` in ``.claude-plugin/plugin.json``. The file +is gitignored and never enters a commit or PR. + +The package is marked ``private`` so ``changeset publish`` skips npm and only +creates the git tag + GitHub release. +""" + +import json +import logging +from pathlib import Path + +logger = logging.getLogger(Path(__file__).stem) + +REPO_ROOT = Path(__file__).resolve().parent.parent + +CLAUDE_PLUGIN_PATH = REPO_ROOT / ".claude-plugin" / "plugin.json" +PACKAGE_JSON_PATH = REPO_ROOT / "package.json" + +# Must match the package name used in changeset frontmatter (``"slack": minor``). +PACKAGE_NAME = "slack" + + +def read_version(plugin_path: Path) -> str: + """Return the ``version`` field from a plugin manifest.""" + manifest = json.loads(plugin_path.read_text()) + return manifest["version"] + + +def seed(plugin_path: Path = CLAUDE_PLUGIN_PATH, package_path: Path = PACKAGE_JSON_PATH) -> str: + """Write an ephemeral ``package.json`` seeded from the plugin manifest. + + Returns the version that was written. + """ + version = read_version(plugin_path) + package = {"name": PACKAGE_NAME, "version": version, "private": True} + package_path.write_text(json.dumps(package, indent=2) + "\n") + logger.info(f"Seeded {package_path} at version {version}") + return version + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + seed() + + +if __name__ == "__main__": + main() diff --git a/scripts/sync_plugin_versions.py b/scripts/sync_plugin_versions.py new file mode 100644 index 0000000..087c5ae --- /dev/null +++ b/scripts/sync_plugin_versions.py @@ -0,0 +1,55 @@ +"""Sync the version from ``package.json`` into the plugin manifests. + +``changeset version`` only knows how to bump ``package.json``. After it runs, this +script copies the resulting version into the two manifests that are the real +distribution artifacts — ``.claude-plugin/plugin.json`` and +``.cursor-plugin/plugin.json`` — so the "Version Packages" PR carries the bump in +both. Key order and 2-space formatting are preserved (matching ``scripts/cursor.py``). +""" + +import json +import logging +from pathlib import Path + +logger = logging.getLogger(Path(__file__).stem) + +REPO_ROOT = Path(__file__).resolve().parent.parent + +PACKAGE_JSON_PATH = REPO_ROOT / "package.json" +PLUGIN_MANIFESTS = ( + REPO_ROOT / ".claude-plugin" / "plugin.json", + REPO_ROOT / ".cursor-plugin" / "plugin.json", +) + + +def read_version(package_path: Path) -> str: + """Return the ``version`` field from ``package.json``.""" + return json.loads(package_path.read_text())["version"] + + +def write_version(manifest_path: Path, version: str) -> None: + """Set the ``version`` field of a manifest, preserving order and formatting.""" + manifest = json.loads(manifest_path.read_text()) + manifest["version"] = version + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n") + logger.info(f"Set {manifest_path} version to {version}") + + +def sync( + package_path: Path = PACKAGE_JSON_PATH, + manifests: tuple[Path, ...] = PLUGIN_MANIFESTS, +) -> str: + """Copy ``package.json``'s version into every manifest. Returns that version.""" + version = read_version(package_path) + for manifest_path in manifests: + write_version(manifest_path, version) + return version + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + sync() + + +if __name__ == "__main__": + main() diff --git a/tests/unit/test_release_scripts.py b/tests/unit/test_release_scripts.py new file mode 100644 index 0000000..da130b2 --- /dev/null +++ b/tests/unit/test_release_scripts.py @@ -0,0 +1,149 @@ +import importlib.util +import json +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +SCRIPTS_DIR = REPO_ROOT / "scripts" + + +def _load(module_name: str): + """Import a script from scripts/ by path (scripts/ is not a package).""" + spec = importlib.util.spec_from_file_location(module_name, SCRIPTS_DIR / f"{module_name}.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +seed_package_json = _load("seed_package_json") +sync_plugin_versions = _load("sync_plugin_versions") +new_changeset = _load("new_changeset") + + +def _write_manifest(path: Path, version: str, **extra) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"name": "slack", "version": version, **extra}, indent=2) + "\n") + + +class TestSeedPackageJson: + def test_seeds_from_plugin_version(self, tmp_path): + plugin = tmp_path / ".claude-plugin" / "plugin.json" + _write_manifest(plugin, "2.3.4") + package = tmp_path / "package.json" + + returned = seed_package_json.seed(plugin_path=plugin, package_path=package) + + data = json.loads(package.read_text()) + assert returned == "2.3.4" + assert data == {"name": "slack", "version": "2.3.4", "private": True} + + def test_private_so_publish_skips_npm(self, tmp_path): + plugin = tmp_path / "plugin.json" + _write_manifest(plugin, "1.0.0") + package = tmp_path / "package.json" + + seed_package_json.seed(plugin_path=plugin, package_path=package) + + assert json.loads(package.read_text())["private"] is True + + def test_idempotent(self, tmp_path): + plugin = tmp_path / "plugin.json" + _write_manifest(plugin, "1.0.0") + package = tmp_path / "package.json" + + seed_package_json.seed(plugin_path=plugin, package_path=package) + first = package.read_text() + seed_package_json.seed(plugin_path=plugin, package_path=package) + + assert package.read_text() == first + + +class TestSyncPluginVersions: + def test_syncs_into_all_manifests(self, tmp_path): + package = tmp_path / "package.json" + package.write_text(json.dumps({"name": "slack", "version": "9.9.9"}) + "\n") + claude = tmp_path / ".claude-plugin" / "plugin.json" + cursor = tmp_path / ".cursor-plugin" / "plugin.json" + _write_manifest(claude, "1.1.0") + _write_manifest(cursor, "1.1.0") + + returned = sync_plugin_versions.sync(package_path=package, manifests=(claude, cursor)) + + assert returned == "9.9.9" + assert json.loads(claude.read_text())["version"] == "9.9.9" + assert json.loads(cursor.read_text())["version"] == "9.9.9" + + def test_preserves_other_fields_and_key_order(self, tmp_path): + package = tmp_path / "package.json" + package.write_text(json.dumps({"name": "slack", "version": "2.0.0"}) + "\n") + manifest = tmp_path / "plugin.json" + manifest.parent.mkdir(parents=True, exist_ok=True) + manifest.write_text( + json.dumps({"name": "slack", "description": "d", "version": "1.0.0"}, indent=2) + "\n" + ) + + sync_plugin_versions.sync(package_path=package, manifests=(manifest,)) + + data = json.loads(manifest.read_text()) + assert data == {"name": "slack", "description": "d", "version": "2.0.0"} + # version stays in its original position (last key) + assert list(data) == ["name", "description", "version"] + + def test_trailing_newline(self, tmp_path): + package = tmp_path / "package.json" + package.write_text(json.dumps({"name": "slack", "version": "2.0.0"}) + "\n") + manifest = tmp_path / "plugin.json" + _write_manifest(manifest, "1.0.0") + + sync_plugin_versions.sync(package_path=package, manifests=(manifest,)) + + assert manifest.read_text().endswith("}\n") + + +class TestNewChangeset: + @pytest.mark.parametrize("bump", ["patch", "minor", "major"]) + def test_render_frontmatter(self, bump): + content = new_changeset.render(bump, "Add a thing") + + assert content == f'---\n"slack": {bump}\n---\n\nAdd a thing\n' + + def test_render_strips_summary(self): + assert new_changeset.render("patch", " trimmed ").endswith("\n\ntrimmed\n") + + def test_write_changeset_creates_parseable_file(self, tmp_path): + path = new_changeset.write_changeset("minor", "New command", directory=tmp_path) + + assert path.parent == tmp_path + assert path.suffix == ".md" + text = path.read_text() + assert text.startswith("---\n") + assert '"slack": minor' in text + assert "New command" in text + + def test_package_name_matches_seed(self): + # The changeset frontmatter key must equal the seeded package.json name, + # or `changeset version` won't bump our package. + assert new_changeset.PACKAGE_NAME == seed_package_json.PACKAGE_NAME + + +class TestVersionRoundTrip: + def test_seed_then_sync_propagates_version(self, tmp_path): + # Mirrors the workflow: seed package.json from plugin.json, (a bump would + # happen here), then sync package.json's version back into the manifests. + claude = tmp_path / ".claude-plugin" / "plugin.json" + cursor = tmp_path / ".cursor-plugin" / "plugin.json" + _write_manifest(claude, "1.1.0") + _write_manifest(cursor, "1.1.0") + package = tmp_path / "package.json" + + seed_package_json.seed(plugin_path=claude, package_path=package) + # Simulate `changeset version` bumping package.json. + bumped = json.loads(package.read_text()) + bumped["version"] = "1.2.0" + package.write_text(json.dumps(bumped, indent=2) + "\n") + + sync_plugin_versions.sync(package_path=package, manifests=(claude, cursor)) + + assert json.loads(claude.read_text())["version"] == "1.2.0" + assert json.loads(cursor.read_text())["version"] == "1.2.0" From de7cce990bb4057c28943927a93ac57fb7c5e559 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 23 Jun 2026 12:20:43 -0400 Subject: [PATCH 02/12] chore(release): replace new_changeset.py with `npx @changesets/cli add` --- .changeset/README.md | 40 +------------- .github/contributing.md | 2 +- .github/maintainers_guide.md | 76 +++++++++++++++------------ AGENTS.md | 10 ++-- Makefile | 5 +- scripts/new_changeset.py | 84 ------------------------------ tests/unit/test_release_scripts.py | 29 ----------- 7 files changed, 56 insertions(+), 190 deletions(-) delete mode 100644 scripts/new_changeset.py diff --git a/.changeset/README.md b/.changeset/README.md index c82509d..54ce920 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -1,41 +1,5 @@ # Changesets -This folder drives the release process. Each user-facing change ships with a -**changeset** — a small markdown file describing the change and how it bumps the -version. On merge to `main`, the release workflow consumes accumulated changesets, -opens a "Version Packages" PR, and (once that PR is merged) tags the release and -publishes a GitHub release. +This directory contains [**Changesets**](https://github.com/changesets/changesets) which are markdown files that describe package changes for the next release. -See [Versioning](../.github/maintainers_guide.md#versioning) for the full flow. - -## Adding a changeset - -You do **not** need Node.js installed. Either run the helper: - -```sh -make changeset -``` - -…or hand-write a file named `.changeset/.md` (the name is arbitrary; one -per PR is conventional) with this format: - -```md ---- -"slack": minor ---- - -Add the channel-digest command -``` - -- The frontmatter key is the package name, always `"slack"` (this repo ships a single - package). The value is the semver bump level: `patch`, `minor`, or `major`. -- The body becomes the changelog entry, so write it for a reader of the release notes: - **what** changed and, when relevant, **why**. - -## Choosing a bump level - -- `patch` — bug fixes and docs/internal changes that don't alter behavior for users. -- `minor` — new skills, commands, or capabilities (backwards compatible). -- `major` — breaking changes to existing behavior. - -A PR with no user-facing change (e.g. CI tweaks) needs no changeset. +For guidance on when and how to add changesets, checkout the [Maintainer's Guide](../.github/maintainers_guide.md#-updating-changesets). diff --git a/.github/contributing.md b/.github/contributing.md index e429ff2..b233c4d 100644 --- a/.github/contributing.md +++ b/.github/contributing.md @@ -39,7 +39,7 @@ For your contribution to be accepted: - [x] The test suite must be complete and pass. - [x] The changes must be approved by code review. - [x] Commits should be atomic and messages must be descriptive. Related issues should be mentioned by Issue number. -- [x] User-facing changes include a changeset (run `make changeset`). See [`.changeset/README.md`](../.changeset/README.md) for the format; releases are automated from these. +- [x] User-facing changes include a changeset (run `make changeset`). See [Updating Changesets](./maintainers_guide.md#-updating-changesets) for the format; releases are automated from these. If the contribution doesn't meet the above criteria, you may fail our automated checks or a maintainer will discuss it with you. You can continue to improve a Pull Request by adding commits to the branch from which the PR was created. diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index c569936..abb0a73 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -46,38 +46,50 @@ source .venv/bin/activate ## Versioning -Follow the [conventional commit specification][conv-commits]. PR titles and -commit messages use prefixes like `feat:`, `fix:`, `chore:`, `docs:`, etc. -First letter after the prefix is lowercase unless it's a proper noun. - -### Releasing (changesets) - -Releases are automated with [changesets][changesets] via -`.github/workflows/release.yml`. There is **no manual version bump or tagging** — -both `.claude-plugin/plugin.json` and `.cursor-plugin/plugin.json` are bumped for you. -The repo stays Node-free on disk: Node runs only in the release workflow, and the -`package.json` changesets needs is generated on the fly (`scripts/seed_package_json.py`) -and gitignored. `.claude-plugin/plugin.json` is the version source of truth. - -**Contributors** add a changeset to any PR with a user-facing change — `make changeset` -or a hand-written `.changeset/.md` (format and bump-level guidance in -[`.changeset/README.md`](../.changeset/README.md)). - -**Maintainers** cut a release by merging PRs: - -1. Merging a feature PR to `main` causes the release workflow to open (or update) a - **"chore: release"** PR. It runs `changeset version`, which computes the next semver, - writes `CHANGELOG.md`, syncs the version into both plugin manifests - (`scripts/sync_plugin_versions.py`), and removes the consumed changesets. Review this - PR like any other — it's where you confirm the resulting version and changelog. -2. Merge the "chore: release" PR. With no changesets left, the workflow runs - `changeset publish`: the package is `private`, so npm is skipped, a `v` git - tag is created, and a GitHub release is published with notes drawn from `CHANGELOG.md`. - -**One-time setup:** enable **Settings → Actions → General → "Allow GitHub Actions to -create and approve pull requests"** so the action can open the release PR. (Note: PRs -opened by the default `GITHUB_TOKEN` don't trigger `ci-build.yml`; the release PR is -mechanical, so this is acceptable.) +Follow the [conventional commit specification][conv-commits]. PR titles and commit messages use prefixes like `feat:`, `fix:`, `chore:`, `docs:`, etc. First letter after the prefix is lowercase unless it's a proper noun. + +### 🎁 Updating Changesets + +This project uses [Changesets](https://github.com/changesets/changesets) to track changes and automate releases. + +Each changeset describes a change to the package and its [semver](https://semver.org/) impact, and a new changeset should be added when updating the package with some change that affects consumers: + +```sh +make changeset +``` + +Alternatively, hand-write a file named `.changeset/.md`, with this format: + +```md +--- +"slack": minor +--- + +Add the channel-digest command +``` + +The frontmatter key is always `"slack"`, the value is the [semver](https://semver.org/) bump level. The body becomes the changelog entry, so write it for a reader of the release notes. + +Updates to documentation, tests, or CI might not require new entries. + +When a PR containing changesets is merged to `main`, a different PR is opened or updated using [changesets/action](https://github.com/changesets/action) which consumes the pending changesets, bumps the package version, and updates the `CHANGELOG` in preparation to release. + +### 🚀 Releases + +Releasing can feel intimidating at first, but don't fret! Venture on! + +New official package versions are published when the release PR created from changesets is merged. Follow these steps to build confidence: + +1. **Run the tests locally**: Before merging the release PR please run all the tests especially the eval ones. If they no longer pass we may need fix it before releasing the changes. + +2. **Check GitHub**: Please check if issues or pull requests are still open either decide to postpone the release or save those changes for a future update. + +3. **Review the release PR**: Verify that the version bump matches expectations, `CHANGELOG` entries are clear, and CI checks pass. + +4. **Merge and approve**: Merge the release PR. It may take up to 24 hours before you see you release in [plugins/slack](https://claude.com/plugins/slack). + +5. **Communicate the release**: + - **External**: Post in relevant channels (e.g. #lang-javascript, #tools-bolt) on [Slack Community](https://community.slack.com/). Include a link to the release notes. ## Everything Else diff --git a/AGENTS.md b/AGENTS.md index 03c6da3..0c47ce2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,12 +52,14 @@ LLM-judged tests are not run in CI (Ollama + model download would exceed time bu ## Releasing Releases are driven by [changesets](https://github.com/changesets/changesets). The -runtime stays pure Python — Node only runs inside the release workflow, and no -`package.json` is committed (it's generated on the fly by `scripts/seed_package_json.py` -and gitignored). `.claude-plugin/plugin.json` is the version source of truth. +plugin runtime stays pure Python — Node is only needed for changesets tooling (`make +changeset` locally and the release workflow in CI), and no `package.json` is committed +(it's generated on the fly by `scripts/seed_package_json.py` and gitignored). +`.claude-plugin/plugin.json` is the version source of truth. **Per change:** every PR with a user-facing change adds a changeset. Run `make changeset` -(or hand-write a `.changeset/.md`); see `.changeset/README.md` for the format and +(or hand-write a `.changeset/.md`); see the +[maintainers guide](.github/maintainers_guide.md#-updating-changesets) for the format and how to pick a bump level. **On merge to `main`** (`.github/workflows/release.yml`): diff --git a/Makefile b/Makefile index ba12fb3..b182881 100644 --- a/Makefile +++ b/Makefile @@ -73,8 +73,9 @@ cursor-install: $(VENV) ## Install this plugin into a local Cursor for developme cursor-uninstall: $(VENV) ## Uninstall this plugin from the local Cursor install $(PYTHON) scripts/cursor.py uninstall -changeset: ## Create a changeset describing a user-facing change (for the next release) - $(PYTHON) scripts/new_changeset.py +changeset: $(VENV) ## Create a changeset describing a user-facing change (for the next release) + $(PYTHON) scripts/seed_package_json.py + npx --yes @changesets/cli add lint: ## Run ruff linter checks $(RUFF) check . diff --git a/scripts/new_changeset.py b/scripts/new_changeset.py deleted file mode 100644 index 4d3e950..0000000 --- a/scripts/new_changeset.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Scaffold a new changeset file so contributors never need Node.js. - -Writes ``.changeset/.md`` with the frontmatter changesets expects: - - --- - "slack": - --- - - - -Runs interactively (prompts for bump level + summary) or non-interactively via -``--bump`` / ``--summary`` flags. -""" - -import argparse -import logging -import random -from pathlib import Path - -logger = logging.getLogger(Path(__file__).stem) - -REPO_ROOT = Path(__file__).resolve().parent.parent -CHANGESET_DIR = REPO_ROOT / ".changeset" - -PACKAGE_NAME = "slack" -BUMP_LEVELS = ("patch", "minor", "major") - -# Used to build memorable, collision-resistant filenames (changesets convention). -ADJECTIVES = ( - "brave", "calm", "clever", "eager", "fancy", "gentle", "happy", "jolly", - "kind", "lively", "proud", "quick", "shiny", "smart", "swift", "witty", -) -ANIMALS = ( - "otters", "pandas", "foxes", "lions", "tigers", "whales", "eagles", - "owls", "bears", "wolves", "moose", "hawks", "seals", "rabbits", -) - - -def random_slug() -> str: - return f"{random.choice(ADJECTIVES)}-{random.choice(ANIMALS)}-{random.randint(100, 999)}" - - -def render(bump: str, summary: str) -> str: - return f'---\n"{PACKAGE_NAME}": {bump}\n---\n\n{summary.strip()}\n' - - -def prompt_bump() -> str: - options = ", ".join(BUMP_LEVELS) - while True: - choice = input(f"Bump level ({options}) [patch]: ").strip().lower() or "patch" - if choice in BUMP_LEVELS: - return choice - print(f"Please choose one of: {options}") - - -def write_changeset(bump: str, summary: str, directory: Path = CHANGESET_DIR) -> Path: - directory.mkdir(parents=True, exist_ok=True) - path = directory / f"{random_slug()}.md" - path.write_text(render(bump, summary)) - return path - - -def main() -> None: - parser = argparse.ArgumentParser( - prog=Path(__file__).name, - description="Create a changeset describing a user-facing change.", - ) - parser.add_argument("--bump", choices=BUMP_LEVELS, help="Semver bump level") - parser.add_argument("--summary", help="Changelog summary for the change") - args = parser.parse_args() - - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") - - bump = args.bump or prompt_bump() - summary = args.summary or input("Summary: ").strip() - if not summary: - parser.error("a non-empty summary is required") - - path = write_changeset(bump, summary) - logger.info(f"Created {path.relative_to(REPO_ROOT)}") - - -if __name__ == "__main__": - main() diff --git a/tests/unit/test_release_scripts.py b/tests/unit/test_release_scripts.py index da130b2..d217fbb 100644 --- a/tests/unit/test_release_scripts.py +++ b/tests/unit/test_release_scripts.py @@ -2,8 +2,6 @@ import json from pathlib import Path -import pytest - REPO_ROOT = Path(__file__).resolve().parent.parent.parent SCRIPTS_DIR = REPO_ROOT / "scripts" @@ -18,7 +16,6 @@ def _load(module_name: str): seed_package_json = _load("seed_package_json") sync_plugin_versions = _load("sync_plugin_versions") -new_changeset = _load("new_changeset") def _write_manifest(path: Path, version: str, **extra) -> None: @@ -101,32 +98,6 @@ def test_trailing_newline(self, tmp_path): assert manifest.read_text().endswith("}\n") -class TestNewChangeset: - @pytest.mark.parametrize("bump", ["patch", "minor", "major"]) - def test_render_frontmatter(self, bump): - content = new_changeset.render(bump, "Add a thing") - - assert content == f'---\n"slack": {bump}\n---\n\nAdd a thing\n' - - def test_render_strips_summary(self): - assert new_changeset.render("patch", " trimmed ").endswith("\n\ntrimmed\n") - - def test_write_changeset_creates_parseable_file(self, tmp_path): - path = new_changeset.write_changeset("minor", "New command", directory=tmp_path) - - assert path.parent == tmp_path - assert path.suffix == ".md" - text = path.read_text() - assert text.startswith("---\n") - assert '"slack": minor' in text - assert "New command" in text - - def test_package_name_matches_seed(self): - # The changeset frontmatter key must equal the seeded package.json name, - # or `changeset version` won't bump our package. - assert new_changeset.PACKAGE_NAME == seed_package_json.PACKAGE_NAME - - class TestVersionRoundTrip: def test_seed_then_sync_propagates_version(self, tmp_path): # Mirrors the workflow: seed package.json from plugin.json, (a bump would From cc9845be662766d5bb22e8d3a2e5693af9b39ba8 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 23 Jun 2026 12:47:28 -0400 Subject: [PATCH 03/12] update --- .github/workflows/release.yml | 3 +-- scripts/changeset_version.sh | 10 +-------- scripts/seed_package_json.py | 28 +++++------------------ scripts/sync_plugin_versions.py | 40 +++++++++------------------------ 4 files changed, 17 insertions(+), 64 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c32a32a..c3de0fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,6 @@ permissions: env: SUPPORTED_PY: "3.14" NODE_VERSION: "20" - CHANGESETS_CLI: "@changesets/cli@^2.27" jobs: release: @@ -52,7 +51,7 @@ jobs: uses: changesets/action@3841a0683d3cfa6dae0f9bb335290003010fe3f0 # v1.9.0 with: version: bash scripts/changeset_version.sh - publish: npx --yes ${{ env.CHANGESETS_CLI }} publish + publish: npx --yes @changesets/cli publish commit: "chore: release" title: "chore: release" env: diff --git a/scripts/changeset_version.sh b/scripts/changeset_version.sh index 5af640c..33a50d0 100755 --- a/scripts/changeset_version.sh +++ b/scripts/changeset_version.sh @@ -1,13 +1,5 @@ #!/usr/bin/env bash -# The `version` command for changesets/action. -# -# `changeset version` consumes the accumulated changesets: it bumps the ephemeral -# package.json, writes CHANGELOG.md, and deletes the consumed changeset files. We -# then sync the new version into the two plugin manifests so the "Version Packages" -# PR carries the bump everywhere it matters. The action commits all of this. set -euo pipefail -CHANGESETS_CLI="@changesets/cli@^2.27" - -npx --yes "${CHANGESETS_CLI}" version +npx --yes @changesets/cli version python scripts/sync_plugin_versions.py diff --git a/scripts/seed_package_json.py b/scripts/seed_package_json.py index 016b3d3..64dfd2d 100644 --- a/scripts/seed_package_json.py +++ b/scripts/seed_package_json.py @@ -1,15 +1,3 @@ -"""Seed an ephemeral root ``package.json`` for the release workflow. - -Changesets (and the ``changesets/action``) are Node tools that require a -``package.json`` at the repo root to operate. We don't want a Node artifact -committed here, so the release workflow generates one on the fly, seeded from the -real source of truth: the ``version`` in ``.claude-plugin/plugin.json``. The file -is gitignored and never enters a commit or PR. - -The package is marked ``private`` so ``changeset publish`` skips npm and only -creates the git tag + GitHub release. -""" - import json import logging from pathlib import Path @@ -31,21 +19,15 @@ def read_version(plugin_path: Path) -> str: return manifest["version"] -def seed(plugin_path: Path = CLAUDE_PLUGIN_PATH, package_path: Path = PACKAGE_JSON_PATH) -> str: - """Write an ephemeral ``package.json`` seeded from the plugin manifest. +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + version = read_version(CLAUDE_PLUGIN_PATH) - Returns the version that was written. - """ - version = read_version(plugin_path) package = {"name": PACKAGE_NAME, "version": version, "private": True} - package_path.write_text(json.dumps(package, indent=2) + "\n") - logger.info(f"Seeded {package_path} at version {version}") - return version + PACKAGE_JSON_PATH.write_text(json.dumps(package, indent=2) + "\n") -def main() -> None: - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") - seed() + logger.info(f"Seeded {PACKAGE_JSON_PATH} at version {version}") if __name__ == "__main__": diff --git a/scripts/sync_plugin_versions.py b/scripts/sync_plugin_versions.py index 087c5ae..54c0ded 100644 --- a/scripts/sync_plugin_versions.py +++ b/scripts/sync_plugin_versions.py @@ -1,12 +1,3 @@ -"""Sync the version from ``package.json`` into the plugin manifests. - -``changeset version`` only knows how to bump ``package.json``. After it runs, this -script copies the resulting version into the two manifests that are the real -distribution artifacts — ``.claude-plugin/plugin.json`` and -``.cursor-plugin/plugin.json`` — so the "Version Packages" PR carries the bump in -both. Key order and 2-space formatting are preserved (matching ``scripts/cursor.py``). -""" - import json import logging from pathlib import Path @@ -16,10 +7,8 @@ REPO_ROOT = Path(__file__).resolve().parent.parent PACKAGE_JSON_PATH = REPO_ROOT / "package.json" -PLUGIN_MANIFESTS = ( - REPO_ROOT / ".claude-plugin" / "plugin.json", - REPO_ROOT / ".cursor-plugin" / "plugin.json", -) +CLAUDE_PLUGIN_PATH = REPO_ROOT / ".claude-plugin" / "plugin.json" +CURSOR_PLUGIN_PATH = REPO_ROOT / ".cursor-plugin" / "plugin.json" def read_version(package_path: Path) -> str: @@ -27,28 +16,19 @@ def read_version(package_path: Path) -> str: return json.loads(package_path.read_text())["version"] -def write_version(manifest_path: Path, version: str) -> None: - """Set the ``version`` field of a manifest, preserving order and formatting.""" - manifest = json.loads(manifest_path.read_text()) +def write_version(plugin_path: Path, version: str) -> None: + """Set the ``version`` field of a plugin file, preserving order and formatting.""" + manifest = json.loads(plugin_path.read_text()) manifest["version"] = version - manifest_path.write_text(json.dumps(manifest, indent=2) + "\n") - logger.info(f"Set {manifest_path} version to {version}") - - -def sync( - package_path: Path = PACKAGE_JSON_PATH, - manifests: tuple[Path, ...] = PLUGIN_MANIFESTS, -) -> str: - """Copy ``package.json``'s version into every manifest. Returns that version.""" - version = read_version(package_path) - for manifest_path in manifests: - write_version(manifest_path, version) - return version + plugin_path.write_text(json.dumps(manifest, indent=2) + "\n") + logger.info(f"Set {plugin_path} version to {version}") def main() -> None: logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") - sync() + version = read_version(PACKAGE_JSON_PATH) + write_version(CLAUDE_PLUGIN_PATH, version) + write_version(CURSOR_PLUGIN_PATH, version) if __name__ == "__main__": From eb2f10a94ecfb11540fe2af0ee3c1eaac6a47bec Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 23 Jun 2026 15:43:14 -0400 Subject: [PATCH 04/12] use package.json --- .github/workflows/release.yml | 31 ++--- .gitignore | 3 - AGENTS.md | 10 +- Makefile | 3 +- package.json | 5 + scripts/changeset_version.sh | 2 +- scripts/seed_package_json.py | 34 ----- ...nc_plugin_versions.py => sync_versions.py} | 5 +- tests/unit/test_release_scripts.py | 120 ------------------ 9 files changed, 25 insertions(+), 188 deletions(-) create mode 100644 package.json delete mode 100644 scripts/seed_package_json.py rename scripts/{sync_plugin_versions.py => sync_versions.py} (80%) delete mode 100644 tests/unit/test_release_scripts.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3de0fd..ce83231 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,5 @@ name: Release -# Drives the changesets release flow. On every push to main: -# - If changesets are pending, open/update a "chore: release" PR that bumps the -# version (package.json + both plugin manifests) and writes CHANGELOG.md. -# - Once that PR is merged (no changesets remain), tag the release and publish a -# GitHub release. The package is private, so npm publish is skipped. -# See .changeset/README.md and .github/maintainers_guide.md for the full process. - on: push: branches: @@ -16,43 +9,37 @@ on: # Don't let two release runs race on the same branch. concurrency: ${{ github.workflow }}-${{ github.ref }} -permissions: - contents: write # create commits, tags, and releases - pull-requests: write # open/update the version PR - env: - SUPPORTED_PY: "3.14" - NODE_VERSION: "20" + PY_VERSION: "3.14" + NODE_VERSION: "26" jobs: release: name: Release runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: write + pull-requests: write steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - # Full history so changesets can resolve changed files; credentials are - # persisted (the action pushes the version commit and the release tag). fetch-depth: 0 - name: Set up Node ${{ env.NODE_VERSION }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ env.NODE_VERSION }} - - name: Set up Python ${{ env.SUPPORTED_PY }} + - name: Set up Python ${{ env.PY_VERSION }} uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: ${{ env.SUPPORTED_PY }} - - name: Seed ephemeral package.json - # Generates a gitignored package.json from .claude-plugin/plugin.json so the - # Node-based changesets tooling has the root manifest it requires. - run: python scripts/seed_package_json.py + python-version: ${{ env.PY_VERSION }} - name: Changesets release - uses: changesets/action@3841a0683d3cfa6dae0f9bb335290003010fe3f0 # v1.9.0 + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 with: version: bash scripts/changeset_version.sh publish: npx --yes @changesets/cli publish commit: "chore: release" title: "chore: release" + prDraft: create env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 2a0bb2c..94f6884 100644 --- a/.gitignore +++ b/.gitignore @@ -11,9 +11,6 @@ node_modules/ package-lock.json *.tgz -# Ephemeral root manifest seeded by scripts/seed_package_json.py during release. -# plugin.json is the source of truth; this is never committed. -/package.json # Build dist/ diff --git a/AGENTS.md b/AGENTS.md index 0c47ce2..4a57899 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,9 +53,9 @@ LLM-judged tests are not run in CI (Ollama + model download would exceed time bu Releases are driven by [changesets](https://github.com/changesets/changesets). The plugin runtime stays pure Python — Node is only needed for changesets tooling (`make -changeset` locally and the release workflow in CI), and no `package.json` is committed -(it's generated on the fly by `scripts/seed_package_json.py` and gitignored). -`.claude-plugin/plugin.json` is the version source of truth. +changeset` locally and the release workflow in CI). A minimal `package.json` (committed at +the repo root) is the version source of truth; `scripts/sync_versions.py` propagates +its version into both `plugin.json` manifests. **Per change:** every PR with a user-facing change adds a changeset. Run `make changeset` (or hand-write a `.changeset/.md`); see the @@ -66,8 +66,8 @@ how to pick a bump level. 1. If changesets are pending, the `changesets/action` opens/updates a **"chore: release"** PR that runs `changeset version` — bumping `package.json`, syncing the version into - both `plugin.json` manifests (`scripts/sync_plugin_versions.py`), writing `CHANGELOG.md`, - and deleting the consumed changesets. + both `plugin.json` manifests (`scripts/sync_versions.py`), + writing `CHANGELOG.md`, and deleting the consumed changesets. 2. Merging that PR (no changesets left) triggers `changeset publish`, which — because the package is `private` — skips npm, creates the `v` git tag, and publishes a GitHub release with notes from `CHANGELOG.md`. diff --git a/Makefile b/Makefile index b182881..5a6a8bf 100644 --- a/Makefile +++ b/Makefile @@ -73,8 +73,7 @@ cursor-install: $(VENV) ## Install this plugin into a local Cursor for developme cursor-uninstall: $(VENV) ## Uninstall this plugin from the local Cursor install $(PYTHON) scripts/cursor.py uninstall -changeset: $(VENV) ## Create a changeset describing a user-facing change (for the next release) - $(PYTHON) scripts/seed_package_json.py +changeset: ## Create a changeset describing a user-facing change (for the next release) npx --yes @changesets/cli add lint: ## Run ruff linter checks diff --git a/package.json b/package.json new file mode 100644 index 0000000..9071f82 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "name": "slack", + "version": "1.1.0", + "private": true +} diff --git a/scripts/changeset_version.sh b/scripts/changeset_version.sh index 33a50d0..22bc1c9 100755 --- a/scripts/changeset_version.sh +++ b/scripts/changeset_version.sh @@ -2,4 +2,4 @@ set -euo pipefail npx --yes @changesets/cli version -python scripts/sync_plugin_versions.py +python scripts/sync_versions.py diff --git a/scripts/seed_package_json.py b/scripts/seed_package_json.py deleted file mode 100644 index 64dfd2d..0000000 --- a/scripts/seed_package_json.py +++ /dev/null @@ -1,34 +0,0 @@ -import json -import logging -from pathlib import Path - -logger = logging.getLogger(Path(__file__).stem) - -REPO_ROOT = Path(__file__).resolve().parent.parent - -CLAUDE_PLUGIN_PATH = REPO_ROOT / ".claude-plugin" / "plugin.json" -PACKAGE_JSON_PATH = REPO_ROOT / "package.json" - -# Must match the package name used in changeset frontmatter (``"slack": minor``). -PACKAGE_NAME = "slack" - - -def read_version(plugin_path: Path) -> str: - """Return the ``version`` field from a plugin manifest.""" - manifest = json.loads(plugin_path.read_text()) - return manifest["version"] - - -def main() -> None: - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") - version = read_version(CLAUDE_PLUGIN_PATH) - - package = {"name": PACKAGE_NAME, "version": version, "private": True} - - PACKAGE_JSON_PATH.write_text(json.dumps(package, indent=2) + "\n") - - logger.info(f"Seeded {PACKAGE_JSON_PATH} at version {version}") - - -if __name__ == "__main__": - main() diff --git a/scripts/sync_plugin_versions.py b/scripts/sync_versions.py similarity index 80% rename from scripts/sync_plugin_versions.py rename to scripts/sync_versions.py index 54c0ded..a03a34b 100644 --- a/scripts/sync_plugin_versions.py +++ b/scripts/sync_versions.py @@ -1,11 +1,14 @@ import json import logging +import re from pathlib import Path logger = logging.getLogger(Path(__file__).stem) REPO_ROOT = Path(__file__).resolve().parent.parent +# package.json (committed at the repo root) is the version source of truth; changesets +# bumps it, then this script synchronizes the version out to everywhere else its defined. PACKAGE_JSON_PATH = REPO_ROOT / "package.json" CLAUDE_PLUGIN_PATH = REPO_ROOT / ".claude-plugin" / "plugin.json" CURSOR_PLUGIN_PATH = REPO_ROOT / ".cursor-plugin" / "plugin.json" @@ -17,7 +20,7 @@ def read_version(package_path: Path) -> str: def write_version(plugin_path: Path, version: str) -> None: - """Set the ``version`` field of a plugin file, preserving order and formatting.""" + """Set the ``version`` field of a JSON plugin manifest.""" manifest = json.loads(plugin_path.read_text()) manifest["version"] = version plugin_path.write_text(json.dumps(manifest, indent=2) + "\n") diff --git a/tests/unit/test_release_scripts.py b/tests/unit/test_release_scripts.py deleted file mode 100644 index d217fbb..0000000 --- a/tests/unit/test_release_scripts.py +++ /dev/null @@ -1,120 +0,0 @@ -import importlib.util -import json -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent.parent -SCRIPTS_DIR = REPO_ROOT / "scripts" - - -def _load(module_name: str): - """Import a script from scripts/ by path (scripts/ is not a package).""" - spec = importlib.util.spec_from_file_location(module_name, SCRIPTS_DIR / f"{module_name}.py") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -seed_package_json = _load("seed_package_json") -sync_plugin_versions = _load("sync_plugin_versions") - - -def _write_manifest(path: Path, version: str, **extra) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps({"name": "slack", "version": version, **extra}, indent=2) + "\n") - - -class TestSeedPackageJson: - def test_seeds_from_plugin_version(self, tmp_path): - plugin = tmp_path / ".claude-plugin" / "plugin.json" - _write_manifest(plugin, "2.3.4") - package = tmp_path / "package.json" - - returned = seed_package_json.seed(plugin_path=plugin, package_path=package) - - data = json.loads(package.read_text()) - assert returned == "2.3.4" - assert data == {"name": "slack", "version": "2.3.4", "private": True} - - def test_private_so_publish_skips_npm(self, tmp_path): - plugin = tmp_path / "plugin.json" - _write_manifest(plugin, "1.0.0") - package = tmp_path / "package.json" - - seed_package_json.seed(plugin_path=plugin, package_path=package) - - assert json.loads(package.read_text())["private"] is True - - def test_idempotent(self, tmp_path): - plugin = tmp_path / "plugin.json" - _write_manifest(plugin, "1.0.0") - package = tmp_path / "package.json" - - seed_package_json.seed(plugin_path=plugin, package_path=package) - first = package.read_text() - seed_package_json.seed(plugin_path=plugin, package_path=package) - - assert package.read_text() == first - - -class TestSyncPluginVersions: - def test_syncs_into_all_manifests(self, tmp_path): - package = tmp_path / "package.json" - package.write_text(json.dumps({"name": "slack", "version": "9.9.9"}) + "\n") - claude = tmp_path / ".claude-plugin" / "plugin.json" - cursor = tmp_path / ".cursor-plugin" / "plugin.json" - _write_manifest(claude, "1.1.0") - _write_manifest(cursor, "1.1.0") - - returned = sync_plugin_versions.sync(package_path=package, manifests=(claude, cursor)) - - assert returned == "9.9.9" - assert json.loads(claude.read_text())["version"] == "9.9.9" - assert json.loads(cursor.read_text())["version"] == "9.9.9" - - def test_preserves_other_fields_and_key_order(self, tmp_path): - package = tmp_path / "package.json" - package.write_text(json.dumps({"name": "slack", "version": "2.0.0"}) + "\n") - manifest = tmp_path / "plugin.json" - manifest.parent.mkdir(parents=True, exist_ok=True) - manifest.write_text( - json.dumps({"name": "slack", "description": "d", "version": "1.0.0"}, indent=2) + "\n" - ) - - sync_plugin_versions.sync(package_path=package, manifests=(manifest,)) - - data = json.loads(manifest.read_text()) - assert data == {"name": "slack", "description": "d", "version": "2.0.0"} - # version stays in its original position (last key) - assert list(data) == ["name", "description", "version"] - - def test_trailing_newline(self, tmp_path): - package = tmp_path / "package.json" - package.write_text(json.dumps({"name": "slack", "version": "2.0.0"}) + "\n") - manifest = tmp_path / "plugin.json" - _write_manifest(manifest, "1.0.0") - - sync_plugin_versions.sync(package_path=package, manifests=(manifest,)) - - assert manifest.read_text().endswith("}\n") - - -class TestVersionRoundTrip: - def test_seed_then_sync_propagates_version(self, tmp_path): - # Mirrors the workflow: seed package.json from plugin.json, (a bump would - # happen here), then sync package.json's version back into the manifests. - claude = tmp_path / ".claude-plugin" / "plugin.json" - cursor = tmp_path / ".cursor-plugin" / "plugin.json" - _write_manifest(claude, "1.1.0") - _write_manifest(cursor, "1.1.0") - package = tmp_path / "package.json" - - seed_package_json.seed(plugin_path=claude, package_path=package) - # Simulate `changeset version` bumping package.json. - bumped = json.loads(package.read_text()) - bumped["version"] = "1.2.0" - package.write_text(json.dumps(bumped, indent=2) + "\n") - - sync_plugin_versions.sync(package_path=package, manifests=(claude, cursor)) - - assert json.loads(claude.read_text())["version"] == "1.2.0" - assert json.loads(cursor.read_text())["version"] == "1.2.0" From bd3f325d4d8de583e9b4840fe48e53a65a7760f1 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 23 Jun 2026 16:00:16 -0400 Subject: [PATCH 05/12] Update AGENTS.md --- AGENTS.md | 28 +++++----------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4a57899..51d5616 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,26 +51,8 @@ LLM-judged tests are not run in CI (Ollama + model download would exceed time bu ## Releasing -Releases are driven by [changesets](https://github.com/changesets/changesets). The -plugin runtime stays pure Python — Node is only needed for changesets tooling (`make -changeset` locally and the release workflow in CI). A minimal `package.json` (committed at -the repo root) is the version source of truth; `scripts/sync_versions.py` propagates -its version into both `plugin.json` manifests. - -**Per change:** every PR with a user-facing change adds a changeset. Run `make changeset` -(or hand-write a `.changeset/.md`); see the -[maintainers guide](.github/maintainers_guide.md#-updating-changesets) for the format and -how to pick a bump level. - -**On merge to `main`** (`.github/workflows/release.yml`): - -1. If changesets are pending, the `changesets/action` opens/updates a **"chore: release"** - PR that runs `changeset version` — bumping `package.json`, syncing the version into - both `plugin.json` manifests (`scripts/sync_versions.py`), - writing `CHANGELOG.md`, and deleting the consumed changesets. -2. Merging that PR (no changesets left) triggers `changeset publish`, which — because the - package is `private` — skips npm, creates the `v` git tag, and publishes a - GitHub release with notes from `CHANGELOG.md`. - -A one-time repo setting is required: **Settings → Actions → "Allow GitHub Actions to -create and approve pull requests."** +Releases are automated and run in CI — **you never run a release yourself.** Your only release-related task is adding a changeset when a PR makes a user-facing change. + +See the [maintainers guide](.github/maintainers_guide.md#-updating-changesets) for the format. + +Everything after that is handled by [changesets](https://github.com/changesets/changesets) and `scripts/changeset_version.sh`: merging to `main` opens a "chore: release" PR, and merging that PR publishes the release. From ce0edf6454e1fb6c4d93e7afe1ad154d900118b4 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 23 Jun 2026 16:16:46 -0400 Subject: [PATCH 06/12] chore(release): drop unused import in sync_versions.py Co-Authored-By: Claude --- scripts/sync_versions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/sync_versions.py b/scripts/sync_versions.py index a03a34b..12bb3f1 100644 --- a/scripts/sync_versions.py +++ b/scripts/sync_versions.py @@ -1,6 +1,5 @@ import json import logging -import re from pathlib import Path logger = logging.getLogger(Path(__file__).stem) From 2bfbc2c0dc237785de1cd1e4861ec8e3ef0f2589 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 2 Jul 2026 06:23:37 -0700 Subject: [PATCH 07/12] Update .github/maintainers_guide.md Co-authored-by: Eden Zimbelman --- .github/maintainers_guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index abb0a73..e84c41a 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -86,7 +86,7 @@ New official package versions are published when the release PR created from cha 3. **Review the release PR**: Verify that the version bump matches expectations, `CHANGELOG` entries are clear, and CI checks pass. -4. **Merge and approve**: Merge the release PR. It may take up to 24 hours before you see you release in [plugins/slack](https://claude.com/plugins/slack). +4. **Merge and approve**: Merge the release PR. It may take up to 24 hours before you see you release in the [Claude Plugins](https://claude.com/plugins/slack) directory. 5. **Communicate the release**: - **External**: Post in relevant channels (e.g. #lang-javascript, #tools-bolt) on [Slack Community](https://community.slack.com/). Include a link to the release notes. From bb733a86efa3fb6247b908e14b39c77c0a455f3b Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 2 Jul 2026 06:23:58 -0700 Subject: [PATCH 08/12] Update .github/workflows/release.yml Co-authored-by: Eden Zimbelman --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce83231..093f393 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 + persist-credentials: false - name: Set up Node ${{ env.NODE_VERSION }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: From 4848fb9172311b38c472f6b8dbd68661e2100c93 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 2 Jul 2026 09:42:56 -0400 Subject: [PATCH 09/12] Clean up maintainer-guide --- .github/maintainers_guide.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index abb0a73..9aa499c 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -26,7 +26,7 @@ brew update brew install pyenv ``` -Install necessary Python runtimes for development/testing. You can rely on GitHub Actions workflows for testing with various major versions. +Install necessary Python runtime for development/testing. ```sh $ pyenv install 3.14 # select the latest patch version @@ -52,7 +52,7 @@ Follow the [conventional commit specification][conv-commits]. PR titles and comm This project uses [Changesets](https://github.com/changesets/changesets) to track changes and automate releases. -Each changeset describes a change to the package and its [semver](https://semver.org/) impact, and a new changeset should be added when updating the package with some change that affects consumers: +Each changeset describes a change to the package and its [semver][semver] impact, and a new changeset should be added when updating the package with some change that affects consumers: ```sh make changeset @@ -68,7 +68,7 @@ Alternatively, hand-write a file named `.changeset/.md`, with this for Add the channel-digest command ``` -The frontmatter key is always `"slack"`, the value is the [semver](https://semver.org/) bump level. The body becomes the changelog entry, so write it for a reader of the release notes. +The frontmatter key is always `"slack"`; the value is the [semver][semver] bump level, like `patch`, `minor`, or `major`. The body becomes the changelog entry, so write it for a reader of the release notes. Updates to documentation, tests, or CI might not require new entries. @@ -119,4 +119,3 @@ Patch and minor updates are auto-approved and auto-merged via the [gh-cli]: https://cli.github.com [conv-commits]: https://www.conventionalcommits.org [semver]: https://semver.org -[changesets]: https://github.com/changesets/changesets From 0d3c3cd531f21585a8a5bfba63ab1a9f494fdd68 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 2 Jul 2026 10:23:48 -0400 Subject: [PATCH 10/12] add changeset to package.json --- .github/contributing.md | 2 +- .github/dependabot.yml | 13 +++++++++---- .github/maintainers_guide.md | 2 +- .github/workflows/release.yml | 4 +++- AGENTS.md | 1 - Makefile | 7 ++----- package.json | 5 ++++- scripts/changeset_version.sh | 2 +- 8 files changed, 21 insertions(+), 15 deletions(-) diff --git a/.github/contributing.md b/.github/contributing.md index b233c4d..3886a77 100644 --- a/.github/contributing.md +++ b/.github/contributing.md @@ -39,7 +39,7 @@ For your contribution to be accepted: - [x] The test suite must be complete and pass. - [x] The changes must be approved by code review. - [x] Commits should be atomic and messages must be descriptive. Related issues should be mentioned by Issue number. -- [x] User-facing changes include a changeset (run `make changeset`). See [Updating Changesets](./maintainers_guide.md#-updating-changesets) for the format; releases are automated from these. +- [x] User-facing changes include a changeset (run `npx changeset add`). See [Updating Changesets](./maintainers_guide.md#-updating-changesets) for the format; releases are automated from these. If the contribution doesn't meet the above criteria, you may fail our automated checks or a maintainer will discuss it with you. You can continue to improve a Pull Request by adding commits to the branch from which the PR was created. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6da89d5..cb11a78 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,12 +6,17 @@ updates: interval: "monthly" open-pull-requests-limit: 5 labels: - - "build" - - "semver:patch" + - "dependencies" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" labels: - - "build" - - "semver:patch" + - "dependencies" + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "monthly" + versioning-strategy: increase + labels: + - "dependencies" diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index cb349c0..7ce7423 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -55,7 +55,7 @@ This project uses [Changesets](https://github.com/changesets/changesets) to trac Each changeset describes a change to the package and its [semver][semver] impact, and a new changeset should be added when updating the package with some change that affects consumers: ```sh -make changeset +npx changeset add ``` Alternatively, hand-write a file named `.changeset/.md`, with this format: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 093f393..9de7060 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,11 +34,13 @@ jobs: uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ env.PY_VERSION }} + - name: Install Node dependencies + run: npm install - name: Changesets release uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 with: version: bash scripts/changeset_version.sh - publish: npx --yes @changesets/cli publish + publish: npx changeset publish commit: "chore: release" title: "chore: release" prDraft: create diff --git a/AGENTS.md b/AGENTS.md index 96ded23..4af57f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,6 @@ Requires Python 3.14+. Run `make install` before first use to set up the virtual | `make clean` | Remove .venv and .ollama | | `make cursor-install` | Install this plugin into a local Cursor for development | | `make cursor-uninstall` | Uninstall this plugin from the local Cursor install | -| `make changeset` | Create a changeset for the next release (see Releasing) | The LLM tests read two environment variables: `OLLAMA_MODEL_NAME` (the DeepEval judge model, defaults to `gemma4`) and `SLACK_MCP_TOKEN` (a Slack MCP bearer token; the MCP tool-selection test is skipped when it's unset). Copy `.env.example` to `.env` and fill in values — the `Makefile` auto-loads `.env` — or pass them inline, e.g. `OLLAMA_MODEL_NAME= make test-eval`. diff --git a/Makefile b/Makefile index 5a6a8bf..a700410 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ OLLAMA_MODEL := $(or $(OLLAMA_MODEL_NAME),gemma4) UNAME_S := $(shell uname -s) -TARGETS := help install install-test install-tools clean lint format test test-unit test-eval cursor-install cursor-uninstall changeset +TARGETS := help install install-test install-tools clean lint format test test-unit test-eval cursor-install cursor-uninstall .PHONY: $(TARGETS) @@ -65,7 +65,7 @@ install-tools: $(VENV) ## Install linting/formatting tools (ruff) clean: ## Remove virtual environment, Ollama, and local Cursor install -$(PYTHON) scripts/cursor.py uninstall - rm -rf $(VENV) $(OLLAMA_DIR) + rm -rf $(VENV) $(OLLAMA_DIR) node_modules cursor-install: $(VENV) ## Install this plugin into a local Cursor for development $(PYTHON) scripts/cursor.py install @@ -73,9 +73,6 @@ cursor-install: $(VENV) ## Install this plugin into a local Cursor for developme cursor-uninstall: $(VENV) ## Uninstall this plugin from the local Cursor install $(PYTHON) scripts/cursor.py uninstall -changeset: ## Create a changeset describing a user-facing change (for the next release) - npx --yes @changesets/cli add - lint: ## Run ruff linter checks $(RUFF) check . diff --git a/package.json b/package.json index 9071f82..a59ab8e 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,8 @@ { "name": "slack", "version": "1.1.0", - "private": true + "private": true, + "devDependencies": { + "@changesets/cli": "^2.31.0" + } } diff --git a/scripts/changeset_version.sh b/scripts/changeset_version.sh index 22bc1c9..6ee35ab 100755 --- a/scripts/changeset_version.sh +++ b/scripts/changeset_version.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash set -euo pipefail -npx --yes @changesets/cli version +npx changeset version python scripts/sync_versions.py From a7e1075916512dffa963a85ebcd9f811b61d4c59 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 2 Jul 2026 10:57:40 -0400 Subject: [PATCH 11/12] improve based on feedback --- .changeset/config.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.changeset/config.json b/.changeset/config.json index 28dcd20..0d310a4 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,5 +8,8 @@ "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [], - "privatePackages": { "version": true, "tag": true } + "privatePackages": { + "version": true, + "tag": true + } } From 4d7821c06bc184167eb3be138a677ec5f93ffc87 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 2 Jul 2026 15:07:20 -0400 Subject: [PATCH 12/12] chore: require Node.js 26+ via engines field Co-Authored-By: Claude --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index a59ab8e..3748173 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "slack", "version": "1.1.0", "private": true, + "engines": { + "node": ">=26" + }, "devDependencies": { "@changesets/cli": "^2.31.0" }