Repository navigation
Expand file tree
/
Copy pathbump-version.py
More file actions
executable file
·357 lines (298 loc) · 13.8 KB
/
Copy pathbump-version.py
File metadata and controls
executable file
·357 lines (298 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
#!/usr/bin/env python3
"""Bump plugin versions in lockstep across a plugin's manifest and the marketplace entry.
A plugin's version is the *only* signal Claude Code has that an installed copy is stale.
The install cache is keyed by version:
<cache>/maintainerd/audits/0.1.0/skills/audit-tests/SKILL.md
so a merge that changes a skill but leaves `0.1.0` alone lands nowhere — every existing
install keeps serving the old content forever. Bumping is therefore not a release
ceremony here, it is how the change reaches anyone.
The version lives in the places that CI (`.github/workflows/validate.yml`) requires to
agree: `plugins/<dir>/.claude-plugin/plugin.json` (Claude Code's manifest), the plugin's
entry in `.claude-plugin/marketplace.json`, and — where present — `plugins/<dir>/plugin.json`
(the Agent Plugins v1.0.0 manifest, kept for clients that speak the open spec instead of
Claude Code's format). This script edits all of them or none.
scripts/bump-version.py core repo-ops # bump these (patch)
scripts/bump-version.py --level minor audits # bump one, minor
scripts/bump-version.py --changed-since HEAD~1 # bump whatever that range touched
scripts/bump-version.py --changed-since abc123 --dry-run
Plugins are named by either source directory (`core`) or plugin name (`maintainerd-core`).
stdout is the machine-readable part: one `<name>--v<version>` git tag per bumped plugin,
matching the tag convention `claude plugin tag` uses. Everything human goes to stderr, so
`TAGS=$(scripts/bump-version.py ...)` composes cleanly in CI.
Edits are made as targeted text replacements rather than a json.dump round-trip, which
would reflow these hand-formatted manifests and bury a one-character change in a
whole-file diff. Every edit is verified by reparsing the result.
"""
import argparse
import json
import os
import re
import subprocess
import sys
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
MARKETPLACE = os.path.join(ROOT, ".claude-plugin", "marketplace.json")
SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
# Matches a whole `"version": "..."` line. Anchored to line start so a `version` key
# nested inside some future object still needs the uniqueness check below to pass.
VERSION_LINE = re.compile(r'(?m)^(\s*"version"\s*:\s*")([^"]*)(")')
def fail(message):
print(f"error: {message}", file=sys.stderr)
sys.exit(1)
def log(message):
print(message, file=sys.stderr)
def git(*args):
"""Run a git command in ROOT, returning stdout. Raises on non-zero exit."""
return subprocess.run(
["git", "-C", ROOT, *args],
check=True,
capture_output=True,
text=True,
).stdout
def parsed(version):
"""(major, minor, patch) for a plain X.Y.Z string, else None. Orders correctly."""
match = SEMVER.match(version) if isinstance(version, str) else None
return tuple(int(part) for part in match.groups()) if match else None
def bump(version, level):
numbers = parsed(version)
if numbers is None:
# Prerelease/build metadata would need a policy this repo has not picked. Refuse
# rather than guess: a wrong bump is published to every installed copy.
fail(f"version {version!r} is not a plain X.Y.Z semver — bump it by hand")
major, minor, patch = numbers
if level == "major":
return f"{major + 1}.0.0"
if level == "minor":
return f"{major}.{minor + 1}.0"
return f"{major}.{minor}.{patch + 1}"
def load_marketplace():
with open(MARKETPLACE) as handle:
text = handle.read()
return text, json.loads(text)
def plugin_index(marketplace):
"""Map both the source directory name and the plugin name to one entry record."""
index = {}
for entry in marketplace["plugins"]:
source = entry["source"].lstrip("./").rstrip("/")
record = {
"name": entry["name"],
"source": source,
"dir": os.path.basename(source),
"version": entry.get("version"),
"manifest": os.path.join(source, ".claude-plugin", "plugin.json"),
"manifest_open_spec": os.path.join(source, "plugin.json"),
}
index[record["name"]] = record
index[record["dir"]] = record
return index
def replace_plugin_version(path, old, new):
"""Rewrite the version in a plugin.json, verifying the parse afterwards."""
with open(path) as handle:
text = handle.read()
matches = VERSION_LINE.findall(text)
if len(matches) != 1:
fail(f"{path}: expected exactly one top-level \"version\" line, found {len(matches)}")
if matches[0][1] != old:
fail(f"{path}: version is {matches[0][1]!r}, expected {old!r}")
updated = VERSION_LINE.sub(lambda m: f"{m.group(1)}{new}{m.group(3)}", text, count=1)
verify(updated, path, lambda data: data.get("version"), new)
return updated
def replace_marketplace_version(text, name, old, new):
"""Rewrite one entry's version in marketplace.json, verifying the parse afterwards.
The entry is located by its `"name"` key and the *next* `"version"` after it. If any
other `"name"` key intervenes we have walked into the following entry — which would
mean this entry carries no version — so bail rather than corrupt a sibling.
"""
anchor = re.search(rf'"name"\s*:\s*"{re.escape(name)}"', text)
if not anchor:
fail(f"marketplace.json: no entry named {name!r}")
tail = text[anchor.end():]
version = re.search(r'("version"\s*:\s*")([^"]*)(")', tail)
if not version:
fail(f"marketplace.json: entry {name!r} has no version field")
intervening = re.search(r'"name"\s*:\s*"', tail[: version.start()])
if intervening:
fail(f"marketplace.json: entry {name!r} has no version before the next entry")
if version.group(2) != old:
fail(f"marketplace.json: {name} version is {version.group(2)!r}, expected {old!r}")
start = anchor.end() + version.start()
end = anchor.end() + version.end()
updated = text[:start] + f"{version.group(1)}{new}{version.group(3)}" + text[end:]
verify(
updated,
"marketplace.json",
lambda data: next(e for e in data["plugins"] if e["name"] == name).get("version"),
new,
)
return updated
def verify(text, label, extract, expected):
"""Reparse an edited manifest and confirm the edit landed where it was aimed.
The text edits above are careful, but "careful" is not "checked" — and a manifest
this script mangles ships straight to every install.
"""
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
fail(f"{label}: edit produced invalid JSON ({exc})")
actual = extract(data)
if actual != expected:
fail(f"{label}: edit left version at {actual!r}, expected {expected!r}")
def version_at(ref, manifest):
"""The plugin's version at `ref`, or None if the manifest did not exist there."""
try:
text = git("show", f"{ref}:{manifest}")
except subprocess.CalledProcessError:
return None
try:
return json.loads(text).get("version")
except json.JSONDecodeError:
return None
def changed_plugins(ref, index):
"""Source dirs under plugins/ touched between `ref` and HEAD, in marketplace order."""
try:
changed = git("diff", "--name-only", ref, "HEAD").splitlines()
except subprocess.CalledProcessError:
fail(f"cannot diff against {ref!r} — is it a valid ref in this repo?")
touched = set()
for path in changed:
parts = path.split("/")
if len(parts) > 2 and parts[0] == "plugins":
touched.add("/".join(parts[:2]))
selected = []
for record in ordered(index):
if record["source"] in touched:
selected.append(record)
unknown = touched - {record["source"] for record in ordered(index)}
for source in sorted(unknown):
# A directory under plugins/ with no marketplace entry ships to nobody, so there
# is no install to invalidate. Worth saying out loud — it is usually a typo in
# marketplace.json rather than a deliberate unlisted plugin.
log(f"note: {source} changed but has no marketplace entry — skipping")
return selected
def ordered(index):
"""The distinct entry records, in marketplace order (index holds each one twice)."""
seen = []
for record in index.values():
if record not in seen:
seen.append(record)
return seen
def main():
parser = argparse.ArgumentParser(
description="Bump plugin versions in plugin.json and marketplace.json together.",
)
parser.add_argument(
"plugins",
nargs="*",
help="plugins to bump, by source directory (core) or plugin name (maintainerd-core)",
)
parser.add_argument(
"--level",
choices=("patch", "minor", "major"),
default="patch",
help="semver level to bump (default: patch)",
)
parser.add_argument(
"--changed-since",
metavar="REF",
help="bump every plugin whose files changed between REF and HEAD",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="report what would change without writing anything",
)
args = parser.parse_args()
if bool(args.plugins) == bool(args.changed_since):
parser.error("give either plugin names or --changed-since, not both or neither")
marketplace_text, marketplace = load_marketplace()
index = plugin_index(marketplace)
if args.changed_since:
targets = changed_plugins(args.changed_since, index)
else:
targets = []
for name in args.plugins:
if name not in index:
fail(f"unknown plugin {name!r} — known: {', '.join(sorted(index))}")
record = index[name]
if record not in targets:
targets.append(record)
tags = []
edits = {}
for record in targets:
manifest_path = os.path.join(ROOT, record["manifest"])
if not os.path.exists(manifest_path):
fail(f"{record['name']}: no manifest at {record['manifest']}")
with open(manifest_path) as handle:
current = json.load(handle).get("version")
if current != record["version"]:
fail(
f"{record['name']}: manifest version {current!r} != marketplace "
f"{record['version']!r} — fix the mismatch before bumping"
)
open_spec_path = os.path.join(ROOT, record["manifest_open_spec"])
has_open_spec = os.path.exists(open_spec_path)
if has_open_spec:
with open(open_spec_path) as handle:
open_spec_current = json.load(handle).get("version")
if open_spec_current != current:
fail(
f"{record['name']}: {record['manifest_open_spec']} version "
f"{open_spec_current!r} != {record['manifest']} {current!r} — "
"fix the mismatch before bumping"
)
# An author who deliberately bumped in the PR (a minor or major that this
# script's default patch would flatten) already published a new version for this
# range. Bumping again on merge would silently turn their 0.2.0 into 0.2.1.
#
# Their version still wants a tag, though: stdout means "this range published
# this version", not "this script edited a file". A brand-new plugin is the one
# case with nothing to announce — its first version has never been anywhere.
if args.changed_since:
previous = version_at(args.changed_since, record["manifest"])
if previous is None:
log(f"skip {record['name']}: new since {args.changed_since}, keeping {current}")
continue
if previous != current:
# A hand-written version is not checked by anything upstream of here, and
# this is the point where it becomes a published tag. A missing one would
# tag `--vNone`; a lower one would hand every install a downgrade that
# the version-keyed cache reads as a fresh release.
before, after = parsed(previous), parsed(current)
if after is None:
fail(f"{record['name']}: version {current!r} is not a plain X.Y.Z semver")
if before is None:
fail(
f"{record['name']}: version at {args.changed_since} was {previous!r}, "
"not a plain X.Y.Z semver — cannot tell whether this is an increase"
)
if after <= before:
fail(
f"{record['name']}: version went {previous} -> {current}; "
"a published version must increase"
)
log(f"skip {record['name']}: already bumped {previous} -> {current}")
tags.append(f"{record['name']}--v{current}")
continue
new = bump(current, args.level)
edits[manifest_path] = replace_plugin_version(manifest_path, current, new)
if has_open_spec:
edits[open_spec_path] = replace_plugin_version(open_spec_path, current, new)
marketplace_text = replace_marketplace_version(
marketplace_text, record["name"], current, new
)
tags.append(f"{record['name']}--v{new}")
log(f"bump {record['name']}: {current} -> {new}")
if not tags:
log("nothing to bump")
return
if args.dry_run:
log("dry run — no files written")
elif edits:
for path, text in edits.items():
with open(path, "w") as handle:
handle.write(text)
with open(MARKETPLACE, "w") as handle:
handle.write(marketplace_text)
for tag in tags:
print(tag)
if __name__ == "__main__":
main()