Skip to content
Open
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
26 changes: 26 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }}
Expand Down
20 changes: 20 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <version>`, 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)
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):**
Expand All @@ -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:**
Expand Down Expand Up @@ -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 <tag>`.
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

Expand Down
79 changes: 79 additions & 0 deletions scripts/bump_version.py
Original file line number Diff line number Diff line change
@@ -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()