diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0966a26..b9bd7fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,13 @@ jobs: contents: write steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: main + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.11' - name: Validate tag format env: @@ -27,6 +34,25 @@ jobs: exit 1 fi + - name: Bump plugin manifests and regenerate manifest + env: + VERSION: ${{ inputs.version }} + run: python3 scripts/bump_version.py "$VERSION" + + - name: Commit version bump + env: + VERSION: ${{ inputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add .claude-plugin/plugin.json .cursor-plugin/plugin.json manifest.json + if git diff --cached --quiet; then + echo "Manifests already at $VERSION; nothing to commit" + else + git commit -s -m "chore(release): bump plugin manifests to $VERSION" + git push origin HEAD:main + fi + - name: Create tag and release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8a3a8d2..e994a98 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,6 +65,26 @@ Examples in skills and references must follow secure defaults: - Prefer scoped tokens over broad credentials - Obfuscate sensitive values: use placeholder workspace IDs (`1111111111111111`), URLs (`company-workspace.cloud.databricks.com`), and never include real tokens or passwords +## Releasing + +Releases are cut by the **Release** workflow (`.github/workflows/release.yml`), +triggered manually (`workflow_dispatch`) with a `vX.Y.Z` tag. The workflow: + +1. Runs `scripts/bump_version.py `, which sets the `version` field in + both `.claude-plugin/plugin.json` and `.cursor-plugin/plugin.json` to the + release version and regenerates `manifest.json`. +2. Commits the bump to `main`. +3. Creates the `vX.Y.Z` tag and GitHub release from that commit. + +Bumping the plugin `version` on every release is **required**: Claude Code's +plugin marketplace keys updates on the `version` field, so a release that ships +without bumping it leaves marketplace clients on the cached copy and they never +see the new skills. + +After releasing, open a follow-up PR to update +[`cli-compat.json`](#version-resolution-in-databricks-cli) in the CLI repo so +`databricks aitools install` resolves to the new version. + ## Version resolution in Databricks CLI The Databricks CLI uses [`cli-compat.json`](https://github.com/databricks/cli/blob/main/internal/build/cli-compat.json) diff --git a/README.md b/README.md index d0d5caa..7af7457 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ but end up loaded by the same agents — pick whichever fits your workflow. - **Databricks CLI** writes SKILL.md files directly into each agent's skill directory (`~/.claude/skills/`, `~/.cursor/extensions/<...>`, etc.). - **Plugin marketplaces** (Claude Code, Cursor) cache the plugin under the - agent's plugin directory (e.g. `~/.claude/plugins/cache/databricks-skills/`); + agent's plugin directory (e.g. `~/.claude/plugins/cache/databricks-agent-skills/`); the agent discovers skills from there. **Via the Databricks CLI (canonical; supports experimental skills):** @@ -35,7 +35,7 @@ skill under [`./skills/`](./skills/)): ```text /plugin marketplace add databricks/databricks-agent-skills -/plugin install databricks-skills +/plugin install databricks@databricks-agent-skills ``` **Via the Cursor plugin marketplace:** @@ -149,7 +149,9 @@ Please see [SECURITY](./SECURITY) for vulnerability reporting guidelines. ## Integrity -All future release tags will be GPG-signed and verifiable via `git tag -v `. +Release tags are created by the [Release workflow](./.github/workflows/release.yml) +and map 1:1 to a published version. (GPG signing is planned once a release signing +key is provisioned.) ## Contributing diff --git a/scripts/bump_version.py b/scripts/bump_version.py new file mode 100644 index 0000000..583518b --- /dev/null +++ b/scripts/bump_version.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Bump the plugin manifest versions to a release version + regenerate manifest. + +Given a `vX.Y.Z` (or `X.Y.Z`) release version, set the `version` field in both +`.claude-plugin/plugin.json` and `.cursor-plugin/plugin.json`, then regenerate +`manifest.json` so the released commit ships a current manifest. + +Why this exists: Claude Code's plugin marketplace keys updates on the `version` +field in `.claude-plugin/plugin.json`. If a release ships without bumping that +field, marketplace clients keep the cached copy and never see the new skills. +This makes the release tag the single source of truth for the plugin version. + +Run by `.github/workflows/release.yml`. Stdlib-only (no pip), so it runs on the +protected runner that can't reach pypi.org. +""" + +import argparse +import re +from pathlib import Path + +import skills # sibling module in scripts/ + +SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$") + +# Matches the first `"version": "..."` field. Both manifests carry exactly one +# top-level `version` key, so a targeted in-place replacement keeps the diff to +# a single line and avoids reformatting the JSON (which a load/dump round-trip +# would risk). +VERSION_FIELD_RE = re.compile(r'("version"\s*:\s*")[^"]*(")') + +PLUGIN_MANIFESTS = ( + Path(".claude-plugin") / "plugin.json", + Path(".cursor-plugin") / "plugin.json", +) + + +def normalize_version(raw: str) -> str: + """Strip a leading `v` and validate the result is `X.Y.Z`.""" + version = raw.strip() + if version.startswith("v"): + version = version[1:] + if not SEMVER_RE.match(version): + raise SystemExit(f"ERROR: version must be vX.Y.Z or X.Y.Z, got {raw!r}") + return version + + +def set_version(path: Path, version: str) -> bool: + """Set the `version` field in a plugin manifest. Returns True if changed.""" + text = path.read_text() + new_text, count = VERSION_FIELD_RE.subn(rf"\g<1>{version}\g<2>", text, count=1) + if count != 1: + raise SystemExit( + f'ERROR: expected exactly one "version" field in {path}, found {count}' + ) + if new_text == text: + return False + path.write_text(new_text) + return True + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version", help="Release version, e.g. v0.3.0") + args = parser.parse_args() + + version = normalize_version(args.version) + repo_root = Path(__file__).resolve().parent.parent + + for rel in PLUGIN_MANIFESTS: + changed = set_version(repo_root / rel, version) + print(f"{'set' if changed else 'unchanged'} {rel} -> {version}") + + manifest = skills.generate_manifest(repo_root) + (repo_root / "manifest.json").write_text(skills.serialize_manifest(manifest)) + print("regenerated manifest.json") + + +if __name__ == "__main__": + main()