Skip to content

Commit 20d3b47

Browse files
authored
ci(release): make the release workflow one-way (publish, never bump) (#170)
## Why The release job currently bumps the `version` in `metaplugin/plugin.meta.json`, pushes that commit to `main`, and then tags. That makes `main` a write target of the release workflow. We want publishing to be one-way: the workflow should only read the version already on `main` and publish it, never write back to the branch. This is the public-repo half of the one-way publish model. It removes the reverse write so the only thing that ever writes to `main` is a merged PR. ## Changes **Before:** dispatch with a `version` input, run `bump_version.py`, commit and push the bump to `main`, tag, release. **Now:** on push to `main` (plus a no-input `workflow_dispatch` re-trigger), read the version from `metaplugin/plugin.meta.json`, skip if that tag already exists, otherwise validate, tag, and cut the release. No bump, no commit, no push to `main`. The only writes are the tag and the GitHub release, both covered by the DECO-SDK-Tagging App's existing ruleset bypass (proven by v0.2.6). How releases are triggered now: a version bump lands on `main` in its own commit (a normal PR) before this runs. Merging that bump fires the workflow, the idempotency guard finds no matching tag, and it tags + releases. Content-only merges find the tag already present and no-op. Merging this PR itself no-ops, since `main` is already at v0.2.6 and that tag exists. `scripts/bump_version.py` is unchanged and still produces the version-bump commit; it is just no longer invoked by this workflow. `CONTRIBUTING.md`'s **Releasing** section is rewritten to match: how to cut a release (bump in a PR, merge it), what does *not* cut a release (content-only merges and a no-input `workflow_dispatch` both no-op via the guard), the App/ruleset that lets the tag through, and how to recover a half-completed release. Behavioral note: `workflow_dispatch` no longer takes a `version` input. A manual dispatch re-publishes the version currently on `main` and no-ops if it is already tagged. It can no longer force-release an arbitrary version. ## Test plan - [x] `release.yml` is valid YAML; a structural check confirms the triggers (`push: main` + no-input dispatch), the idempotency guard, the guarded validate/tag/release steps, and no remaining `inputs.version` or push-to-`main`. - [x] `python3 scripts/skills.py validate` passes on the branch (unaffected by the workflow change). - [x] The guard's tag check verified against the live repo: it skips already-released versions (v0.2.6, v0.2.5, v0.1.0) and matches exactly (no prefix false-positives, e.g. `v0.2` does not match `v0.2.6`). - [x] `CONTRIBUTING.md` Releasing section rewritten to the one-way flow. - [ ] After merge: the `push: main` run reads v0.2.6, finds the tag exists, and no-ops (no spurious release). - [ ] Bump the version via a follow-up PR and confirm the merge fires the workflow, tags, and cuts the release. This pull request and its description were written by Isaac. --------- Signed-off-by: simon <simon.faltum@databricks.com>
1 parent 2cf9ac6 commit 20d3b47

2 files changed

Lines changed: 95 additions & 115 deletions

File tree

.github/workflows/release.yml

Lines changed: 42 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
name: Release
22

3+
# Publishes the version already committed in metaplugin/plugin.meta.json: it
4+
# validates, tags, and cuts the GitHub release, and no-ops if that version is
5+
# already published. It never bumps the version or pushes to main -- the only
6+
# writes are the tag and the release. The version is bumped in a separate
7+
# version-bump commit that lands on main before this runs.
38
on:
4-
workflow_dispatch:
5-
inputs:
6-
version:
7-
description: 'Tag version (e.g. v0.1.0)'
8-
required: true
9-
type: string
9+
push:
10+
branches: [main]
11+
workflow_dispatch: {} # manual re-trigger; no inputs
1012

1113
jobs:
1214
release:
@@ -18,10 +20,10 @@ jobs:
1820
permissions:
1921
contents: write
2022
steps:
21-
# Push as the DECO-SDK-Tagging App, not github-actions[bot]. main's
22-
# rulesets require PR + merge queue and reject a direct bot push; the App
23-
# is on the ruleset bypass list (branch + tag creation), so it can push
24-
# the bump commit and the tag directly. Same pattern as databricks-sdk-go.
23+
# Push the tag as the DECO-SDK-Tagging App, not github-actions[bot]. main's
24+
# rulesets require PR + merge queue and reject a direct bot push; the App is
25+
# on the ruleset bypass list (tag creation), so it can push the tag
26+
# directly. Same pattern as databricks-sdk-go.
2527
- name: Generate GitHub App token
2628
id: generate-token
2729
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
@@ -32,102 +34,57 @@ jobs:
3234
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
3335
with:
3436
ref: main
35-
# Persist the App token so the pushes below carry its credentials.
37+
# Persist the App token so the tag push below carries its credentials.
3638
token: ${{ steps.generate-token.outputs.token }}
3739

3840
- name: Set up Python
3941
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
4042
with:
4143
python-version: '3.11'
4244

43-
- name: Validate tag format
44-
env:
45-
VERSION: ${{ inputs.version }}
45+
- name: Read version from plugin.meta.json
46+
id: version
4647
run: |
47-
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
48-
echo "Error: version must match vX.Y.Z format"
49-
exit 1
48+
V="v$(python3 -c 'import json; print(json.load(open("metaplugin/plugin.meta.json"))["version"])')"
49+
if [[ ! "$V" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
50+
echo "Error: plugin.meta.json version is not vX.Y.Z: $V"; exit 1
5051
fi
52+
echo "version=$V" >> "$GITHUB_OUTPUT"
5153
52-
- name: Bump version in plugin.meta.json and regenerate manifests
54+
- name: Skip if already published
55+
id: guard
5356
env:
54-
VERSION: ${{ inputs.version }}
55-
run: python3 scripts/bump_version.py "$VERSION"
56-
57-
- name: Validate generated manifests before publishing
58-
# Abort the release if anything is inconsistent (drift, a skill missing
59-
# from plugin.meta.json, missing Codex metadata, ...) BEFORE the commit
60-
# and tag are pushed, instead of failing the post-push CI after the
61-
# release is already published.
62-
run: python3 scripts/skills.py validate
63-
64-
- name: Commit version bump
65-
env:
66-
VERSION: ${{ inputs.version }}
57+
VERSION: ${{ steps.version.outputs.version }}
6758
run: |
68-
# Commit as the App. The email must be the App's noreply address or
69-
# the commit will not be verified.
70-
git config user.name "Databricks SDK Release Bot"
71-
git config user.email "DECO-SDK-Tagging[bot]@users.noreply.github.com"
72-
# metaplugin/plugin.meta.json is the source; bump_version.py regenerates
73-
# the full set from it: the four marketplace.json catalogs (Cursor's is
74-
# new), the routing files, the four hook-wiring dialects, manifest.json,
75-
# and the whole plugins/databricks/ bundle (copies + the four plugin.json
76-
# with the bumped version). Stage every one (unchanged files are a no-op)
77-
# so a release can never commit a partial set. The four plugin.json now
78-
# live INSIDE the bundle, so the `plugins/databricks` directory covers
79-
# them. (The catalogs currently pin `ref: main`; once the tag-pinning
80-
# follow-up lands they re-stamp to the new tag here too.)
81-
git add \
82-
metaplugin/plugin.meta.json \
83-
.claude-plugin/marketplace.json \
84-
.github/plugin/marketplace.json \
85-
.agents/plugins/marketplace.json \
86-
.cursor-plugin/marketplace.json \
87-
hooks/_routing_data.json \
88-
rules/databricks-routing.mdc \
89-
rules/README.md \
90-
hooks/hooks.json \
91-
hooks/codex-hooks.json \
92-
hooks/copilot-hooks.json \
93-
hooks/cursor-hooks.json \
94-
manifest.json \
95-
plugins/databricks
96-
# Guard the "stage the full set" invariant: if bump_version.py ever
97-
# regenerates a tracked file not staged above (a new generated artifact
98-
# added without updating this list), fail loudly instead of publishing
99-
# the tag and leaving the regenerated file uncommitted -- which
100-
# post-merge CI on main would then flag as drift.
101-
if ! git diff --quiet; then
102-
echo "Error: bump_version.py changed tracked files not staged for the release commit:"
103-
git diff --name-only
104-
exit 1
105-
fi
106-
if git diff --cached --quiet; then
107-
echo "Manifests already at $VERSION; nothing to commit"
59+
# Query the remote tags directly; no local tag fetch needed.
60+
if git ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null 2>&1; then
61+
echo "already=true" >> "$GITHUB_OUTPUT"
62+
echo "$VERSION is already published; nothing to do."
10863
else
109-
git commit -s -m "chore(release): bump plugin manifests to $VERSION"
110-
git push origin HEAD:main
64+
echo "already=false" >> "$GITHUB_OUTPUT"
11165
fi
11266
67+
- name: Validate manifests before publishing
68+
if: steps.guard.outputs.already == 'false'
69+
# Safety gate: the tree on main is already generated and consistent, so
70+
# this is a no-op pass. It aborts the release if drift somehow reached
71+
# main, before a tag is cut.
72+
run: python3 scripts/skills.py validate
73+
11374
- name: Create annotated tag
75+
if: steps.guard.outputs.already == 'false'
11476
env:
115-
VERSION: ${{ inputs.version }}
77+
VERSION: ${{ steps.version.outputs.version }}
11678
run: |
117-
# git identity is already configured by the "Commit version bump"
118-
# step above and persists for the job via .git/config.
119-
# Annotated tag (objecttype=tag), not lightweight (objecttype=commit):
120-
# records tagger metadata and is the prerequisite for `git tag -v`
121-
# verification if tag signing is ever enabled.
79+
# Email must be the App's noreply address or the tag/commit is unverified.
80+
git config user.name "Databricks SDK Release Bot"
81+
git config user.email "DECO-SDK-Tagging[bot]@users.noreply.github.com"
12282
git tag -a "$VERSION" -m "$VERSION"
12383
git push origin "$VERSION"
12484
12585
- name: Create release
86+
if: steps.guard.outputs.already == 'false'
12687
env:
12788
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
128-
VERSION: ${{ inputs.version }}
129-
run: |
130-
gh release create "$VERSION" \
131-
--title "$VERSION" \
132-
--generate-notes \
133-
--verify-tag
89+
VERSION: ${{ steps.version.outputs.version }}
90+
run: gh release create "$VERSION" --title "$VERSION" --generate-notes --verify-tag

CONTRIBUTING.md

Lines changed: 53 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -156,36 +156,59 @@ Examples in skills and references must follow secure defaults:
156156

157157
## Releasing
158158

159-
Releases are cut by the **Release** workflow (`.github/workflows/release.yml`),
160-
triggered manually (`workflow_dispatch`) with a `vX.Y.Z` tag. The workflow:
161-
162-
1. Runs `scripts/bump_version.py <version>`, which sets the `version` field in
163-
`plugin.meta.json` (the single source) and regenerates every target's
164-
`plugin.json` + each `marketplace.json` catalog, `manifest.json`, and the
165-
`plugins/databricks/` bundle from it, so all four targets carry the same
166-
version.
167-
2. Commits the bump (`plugin.meta.json` + the regenerated catalogs, manifest, and
168-
the `plugins/databricks/` bundle) to `main`.
169-
3. Creates an annotated `vX.Y.Z` tag (`git tag -a`) at that commit, pushes it,
170-
then creates the GitHub release (`gh release create --verify-tag`).
171-
172-
The catalogs currently track `main` (`ref: main`), so the bundle the catalogs
173-
serve is whatever is committed on `main` — releases bump the version but do not
174-
change which ref installs follow. A planned follow-up flips
175-
`marketplace.source.ref_template` to `v{version}`; once it lands, each release
176-
re-stamps the ref-capable catalogs to the new tag, and the release tag must
177-
contain the `plugins/databricks/` bundle (it does, since the bundle is committed
178-
on `main`). Cut that bump-and-tag in one motion so a catalog never names a tag
179-
that does not exist yet.
180-
181-
Bumping the plugin `version` on every release is **required**: Claude Code's
182-
plugin marketplace keys updates on the `version` field, so a release that ships
183-
without bumping it leaves marketplace clients on the cached copy and they never
184-
see the new skills.
185-
186-
After releasing, open a follow-up PR to update
187-
[`cli-compat.json`](#version-resolution-in-databricks-cli) in the CLI repo so
188-
`databricks aitools install` resolves to the new version.
159+
Publishing is **one-way**: the **Release** workflow
160+
(`.github/workflows/release.yml`) only *reads* the version already committed on
161+
`main` and publishes it. It never bumps the version or pushes a commit to
162+
`main`; the only writes it makes are the release tag and the GitHub release.
163+
164+
### Cutting a release
165+
166+
1. **Bump the version in its own PR.** Run `python3 scripts/bump_version.py
167+
vX.Y.Z`, which sets the `version` field in `metaplugin/plugin.meta.json` (the
168+
single source) and regenerates every target from it (each `plugin.json`, the
169+
four `marketplace.json` catalogs, `manifest.json`, and the
170+
`plugins/databricks/` bundle), so all four targets carry the same version.
171+
Commit the regenerated tree and open a PR.
172+
2. **Merge the bump PR.** The push to `main` triggers the Release workflow. It
173+
reads the new version from `plugin.meta.json`, confirms no `vX.Y.Z` tag exists
174+
yet, runs `scripts/skills.py validate` as a safety gate, creates the annotated
175+
`vX.Y.Z` tag, pushes it, and creates the GitHub release (`gh release create
176+
--verify-tag`).
177+
178+
That is the whole release: no manual version argument, no separate publish step.
179+
180+
### What does not cut a release
181+
182+
- **Content-only merges** (anything that leaves the `version` field unchanged):
183+
the workflow still runs on the push to `main`, but the tag for the current
184+
version already exists, so the idempotency guard short-circuits and it no-ops.
185+
- **A manual `workflow_dispatch`** (it takes no inputs): this re-runs the publish
186+
for whatever version is on `main` and no-ops if that version is already tagged.
187+
It cannot force-release an arbitrary version.
188+
189+
The tag is pushed by the DECO-SDK-Tagging GitHub App, which is on the branch and
190+
tag-creation ruleset bypass lists; `main` otherwise requires every change to go
191+
through a PR and the merge queue.
192+
193+
### Notes
194+
195+
- **Bumping the `version` is required** for every release. Claude Code's plugin
196+
marketplace keys updates on the `version` field, so a release that ships
197+
without bumping it leaves marketplace clients on the cached copy and they never
198+
see the new skills.
199+
- The catalogs currently track `main` (`ref: main`), so the bundle they serve is
200+
whatever is committed on `main`; bumping the version does not change which ref
201+
installs follow. A planned follow-up flips `marketplace.source.ref_template` to
202+
`v{version}` so the ref-capable catalogs re-stamp to each release tag (the tag
203+
contains the `plugins/databricks/` bundle, since it is committed on `main`).
204+
- **If a release half-completes** (the tag was pushed but the GitHub release was
205+
not created, for example the job died between the two steps), the guard treats
206+
the version as already published and skips it on the next run. Recover by
207+
creating the release manually (`gh release create vX.Y.Z --verify-tag
208+
--generate-notes`) or by deleting the tag and re-running the workflow.
209+
- After releasing, open a follow-up PR to update
210+
[`cli-compat.json`](#version-resolution-in-databricks-cli) in the CLI repo so
211+
`databricks aitools install` resolves to the new version.
189212

190213
## Version resolution in Databricks CLI
191214

0 commit comments

Comments
 (0)