rlsbl 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/rlsbl/__init__.py +33 -14
- package/rlsbl/commands/check.py +39 -3
- package/rlsbl/commands/config.py +49 -0
- package/rlsbl/commands/init_cmd.py +147 -56
- package/rlsbl/commands/release.py +48 -14
- package/rlsbl/commands/status.py +1 -1
- package/rlsbl/commands/undo.py +66 -0
- package/rlsbl/registries/__init__.py +2 -2
- package/rlsbl/registries/go.py +119 -0
- package/templates/go/ci.yml.tpl +21 -0
- package/templates/go/goreleaser.yml.tpl +25 -0
- package/templates/go/publish.yml.tpl +25 -0
- package/templates/merged/publish.yml.tpl +30 -0
package/package.json
CHANGED
package/rlsbl/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""rlsbl: Release orchestration and project scaffolding for npm and
|
|
1
|
+
"""rlsbl: Release orchestration and project scaffolding for npm, PyPI, and Go."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import sys
|
|
@@ -9,21 +9,23 @@ try:
|
|
|
9
9
|
except Exception:
|
|
10
10
|
__version__ = "unknown"
|
|
11
11
|
|
|
12
|
-
REGISTRIES = ("npm", "pypi")
|
|
13
|
-
COMMANDS = ("release", "status", "scaffold", "check")
|
|
12
|
+
REGISTRIES = ("npm", "pypi", "go")
|
|
13
|
+
COMMANDS = ("release", "status", "scaffold", "check", "config", "undo")
|
|
14
14
|
COMMAND_ALIASES = {"init": "scaffold"}
|
|
15
15
|
|
|
16
16
|
HELP = f"""\
|
|
17
|
-
rlsbl v{__version__} -- Release orchestration and project scaffolding for npm and
|
|
17
|
+
rlsbl v{__version__} -- Release orchestration and project scaffolding for npm, PyPI, and Go
|
|
18
18
|
|
|
19
19
|
Usage:
|
|
20
|
-
rlsbl release [patch|minor|major] [--dry-run] [--quiet] Orchestrate a release
|
|
20
|
+
rlsbl release [patch|minor|major] [--dry-run] [--yes] [--quiet] Orchestrate a release
|
|
21
21
|
rlsbl status Show project status
|
|
22
22
|
rlsbl scaffold [--force] [--update] Scaffold release infrastructure
|
|
23
23
|
rlsbl check <name> Check name availability
|
|
24
|
+
rlsbl config Show project configuration
|
|
25
|
+
rlsbl undo [--yes] Revert the last release
|
|
24
26
|
|
|
25
27
|
Options:
|
|
26
|
-
--registry <npm|pypi> Target a specific registry (auto-detected if omitted)
|
|
28
|
+
--registry <npm|pypi|go> Target a specific registry (auto-detected if omitted)
|
|
27
29
|
--help, -h Show this help
|
|
28
30
|
--version, -v Show version"""
|
|
29
31
|
|
|
@@ -38,6 +40,8 @@ def detect_registries():
|
|
|
38
40
|
found.append("npm")
|
|
39
41
|
if os.path.exists("pyproject.toml"):
|
|
40
42
|
found.append("pypi")
|
|
43
|
+
if os.path.exists("go.mod"):
|
|
44
|
+
found.append("go")
|
|
41
45
|
return found
|
|
42
46
|
|
|
43
47
|
|
|
@@ -80,6 +84,8 @@ def _get_command_module(command):
|
|
|
80
84
|
"status": "status",
|
|
81
85
|
"scaffold": "init_cmd",
|
|
82
86
|
"check": "check",
|
|
87
|
+
"config": "config",
|
|
88
|
+
"undo": "undo",
|
|
83
89
|
}
|
|
84
90
|
module_name = module_map.get(command)
|
|
85
91
|
if not module_name:
|
|
@@ -140,9 +146,10 @@ def main():
|
|
|
140
146
|
if registry:
|
|
141
147
|
handler.run_cmd(registry, args, flags)
|
|
142
148
|
else:
|
|
143
|
-
|
|
149
|
+
all_registries = ["npm", "pypi", "go"]
|
|
150
|
+
for i, r in enumerate(all_registries):
|
|
144
151
|
handler.run_cmd(r, args, flags)
|
|
145
|
-
if i < 1:
|
|
152
|
+
if i < len(all_registries) - 1:
|
|
146
153
|
print("")
|
|
147
154
|
elif command == "scaffold":
|
|
148
155
|
if registry:
|
|
@@ -150,19 +157,31 @@ def main():
|
|
|
150
157
|
else:
|
|
151
158
|
regs = detect_registries()
|
|
152
159
|
if not regs:
|
|
153
|
-
print("Error: no package.json
|
|
160
|
+
print("Error: no package.json, pyproject.toml, or go.mod found.", file=sys.stderr)
|
|
154
161
|
sys.exit(1)
|
|
155
162
|
if len(regs) > 1:
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
163
|
+
handler.run_cmd_multi(regs, args, flags)
|
|
164
|
+
else:
|
|
165
|
+
handler.run_cmd(regs[0], args, flags)
|
|
166
|
+
elif command == "config":
|
|
167
|
+
# config: auto-detect, pass first registry or fallback
|
|
168
|
+
regs = detect_registries()
|
|
169
|
+
handler.run_cmd(registry or (regs[0] if regs else "npm"), args, flags)
|
|
170
|
+
elif command == "undo":
|
|
171
|
+
# undo: auto-detect like release
|
|
172
|
+
if not registry:
|
|
173
|
+
regs = detect_registries()
|
|
174
|
+
if not regs:
|
|
175
|
+
print("Error: no package.json, pyproject.toml, or go.mod found.", file=sys.stderr)
|
|
176
|
+
sys.exit(1)
|
|
177
|
+
registry = regs[0]
|
|
178
|
+
handler.run_cmd(registry, args, flags)
|
|
160
179
|
else:
|
|
161
180
|
# release, status: use explicit registry or auto-detect primary
|
|
162
181
|
if not registry:
|
|
163
182
|
regs = detect_registries()
|
|
164
183
|
if not regs:
|
|
165
|
-
print("Error: no package.json
|
|
184
|
+
print("Error: no package.json, pyproject.toml, or go.mod found.", file=sys.stderr)
|
|
166
185
|
sys.exit(1)
|
|
167
186
|
registry = regs[0]
|
|
168
187
|
handler.run_cmd(registry, args, flags)
|
package/rlsbl/commands/check.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Check command: check package name availability on npm or
|
|
1
|
+
"""Check command: check package name availability on npm, PyPI, or Go (pkg.go.dev)."""
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
import subprocess
|
|
@@ -165,10 +165,44 @@ def _check_name_pypi(name):
|
|
|
165
165
|
)
|
|
166
166
|
|
|
167
167
|
|
|
168
|
+
def check_go_availability(name):
|
|
169
|
+
"""Check if a Go module path exists on pkg.go.dev.
|
|
170
|
+
|
|
171
|
+
Returns {"status": "available"|"taken"|"error", "message"?: str}.
|
|
172
|
+
"""
|
|
173
|
+
url = f"https://pkg.go.dev/{name}"
|
|
174
|
+
try:
|
|
175
|
+
req = urllib.request.Request(url, method="GET")
|
|
176
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
177
|
+
if resp.status == 200:
|
|
178
|
+
return {"status": "taken"}
|
|
179
|
+
return {"status": "error", "message": f"Unexpected status {resp.status}"}
|
|
180
|
+
except urllib.error.HTTPError as e:
|
|
181
|
+
if e.code == 404:
|
|
182
|
+
return {"status": "available"}
|
|
183
|
+
return {"status": "error", "message": f"Unexpected status {e.code}"}
|
|
184
|
+
except Exception as e:
|
|
185
|
+
return {"status": "error", "message": str(e) or "Network error"}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _check_name_go(name):
|
|
189
|
+
"""Check Go module path availability on pkg.go.dev."""
|
|
190
|
+
print(f'Checking pkg.go.dev for "{name}"...')
|
|
191
|
+
|
|
192
|
+
result = check_go_availability(name)
|
|
193
|
+
if result["status"] == "error":
|
|
194
|
+
print(f"Error checking pkg.go.dev: {result['message']}", file=sys.stderr)
|
|
195
|
+
sys.exit(1)
|
|
196
|
+
if result["status"] == "available":
|
|
197
|
+
print(f'"{name}" is available on pkg.go.dev.')
|
|
198
|
+
else:
|
|
199
|
+
print(f'"{name}" already exists on pkg.go.dev.')
|
|
200
|
+
|
|
201
|
+
|
|
168
202
|
def run_cmd(registry, args, flags):
|
|
169
203
|
"""Check command handler.
|
|
170
204
|
|
|
171
|
-
Checks package name availability on npm or
|
|
205
|
+
Checks package name availability on npm, PyPI, or Go, and warns about similar names.
|
|
172
206
|
"""
|
|
173
207
|
name = args[0] if args else None
|
|
174
208
|
if not name:
|
|
@@ -180,5 +214,7 @@ def run_cmd(registry, args, flags):
|
|
|
180
214
|
|
|
181
215
|
if registry == "npm":
|
|
182
216
|
_check_name_npm(name)
|
|
183
|
-
|
|
217
|
+
elif registry == "pypi":
|
|
184
218
|
_check_name_pypi(name)
|
|
219
|
+
elif registry == "go":
|
|
220
|
+
_check_name_go(name)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Config command: show resolved project configuration."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from ..registries import REGISTRIES
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def run_cmd(registry, args, flags):
|
|
8
|
+
print("Detected registries:")
|
|
9
|
+
for name, reg in REGISTRIES.items():
|
|
10
|
+
if reg.check_project_exists("."):
|
|
11
|
+
version = reg.read_version(".")
|
|
12
|
+
vfile = reg.get_version_file() or "git tag"
|
|
13
|
+
print(f" {name}: {vfile} (v{version})")
|
|
14
|
+
else:
|
|
15
|
+
print(f" {name}: not found")
|
|
16
|
+
|
|
17
|
+
print("\nScaffolding:")
|
|
18
|
+
rlsbl_dir = os.path.join(".", ".rlsbl")
|
|
19
|
+
if os.path.isdir(rlsbl_dir):
|
|
20
|
+
version_file = os.path.join(rlsbl_dir, "version")
|
|
21
|
+
if os.path.exists(version_file):
|
|
22
|
+
with open(version_file) as f:
|
|
23
|
+
scaffold_ver = f.read().strip()
|
|
24
|
+
print(f" Version marker: {scaffold_ver}")
|
|
25
|
+
hashes_file = os.path.join(rlsbl_dir, "hashes.json")
|
|
26
|
+
if os.path.exists(hashes_file):
|
|
27
|
+
import json
|
|
28
|
+
with open(hashes_file) as f:
|
|
29
|
+
hashes = json.load(f)
|
|
30
|
+
print(f" Tracked files: {len(hashes)}")
|
|
31
|
+
for path in sorted(hashes):
|
|
32
|
+
print(f" {path}")
|
|
33
|
+
else:
|
|
34
|
+
print(" Not scaffolded (run 'rlsbl scaffold')")
|
|
35
|
+
|
|
36
|
+
print("\nWorkflows:")
|
|
37
|
+
for wf in ["ci.yml", "publish.yml", "workflow.yml"]:
|
|
38
|
+
path = os.path.join(".github", "workflows", wf)
|
|
39
|
+
print(f" {wf}: {'yes' if os.path.exists(path) else 'no'}")
|
|
40
|
+
|
|
41
|
+
print("\nHooks:")
|
|
42
|
+
pre_release = os.path.join("scripts", "pre-release.sh")
|
|
43
|
+
print(f" pre-release.sh: {'yes' if os.path.exists(pre_release) else 'no'}")
|
|
44
|
+
pre_push = os.path.join(".git", "hooks", "pre-push")
|
|
45
|
+
print(f" pre-push hook: {'installed' if os.path.exists(pre_push) else 'not installed'}")
|
|
46
|
+
|
|
47
|
+
print("\nFiles:")
|
|
48
|
+
for f in ["CHANGELOG.md", "LICENSE", ".gitignore", "CLAUDE.md"]:
|
|
49
|
+
print(f" {f}: {'yes' if os.path.exists(f) else 'no'}")
|
|
@@ -60,6 +60,11 @@ NEXT_STEPS = {
|
|
|
60
60
|
"Configure Trusted Publishing on pypi.org",
|
|
61
61
|
"Run rlsbl release [patch|minor|major]",
|
|
62
62
|
],
|
|
63
|
+
"go": [
|
|
64
|
+
"Install GoReleaser (https://goreleaser.com/install/)",
|
|
65
|
+
"Push to GitHub to activate the CI workflow",
|
|
66
|
+
"Run rlsbl release [patch|minor|major]",
|
|
67
|
+
],
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
|
|
@@ -208,52 +213,12 @@ def process_mappings(template_dir, mappings, vars_dict, force, update=False,
|
|
|
208
213
|
return created, skipped, warnings, new_hashes
|
|
209
214
|
|
|
210
215
|
|
|
211
|
-
def
|
|
212
|
-
|
|
216
|
+
def _finalize_scaffold(existing_hashes, all_hash_dicts, created, skipped, warnings,
|
|
217
|
+
registry=None):
|
|
218
|
+
"""Shared post-processing for scaffold: chmod, hooks, version marker, hashes, summary.
|
|
213
219
|
|
|
214
|
-
|
|
215
|
-
from templates.
|
|
220
|
+
all_hash_dicts is a list of dicts to merge into existing_hashes.
|
|
216
221
|
"""
|
|
217
|
-
reg = REGISTRIES[registry]
|
|
218
|
-
|
|
219
|
-
# Check that a project file exists
|
|
220
|
-
if not reg.check_project_exists("."):
|
|
221
|
-
print(f"Error: no {registry} project found in current directory.", file=sys.stderr)
|
|
222
|
-
print(reg.get_project_init_hint(), file=sys.stderr)
|
|
223
|
-
sys.exit(1)
|
|
224
|
-
|
|
225
|
-
# Gather template variables
|
|
226
|
-
vars_dict = reg.get_template_vars(".")
|
|
227
|
-
from datetime import datetime
|
|
228
|
-
vars_dict["year"] = str(datetime.now().year)
|
|
229
|
-
|
|
230
|
-
force = flags.get("force", False)
|
|
231
|
-
update = flags.get("update", False)
|
|
232
|
-
|
|
233
|
-
existing_hashes = load_hashes()
|
|
234
|
-
|
|
235
|
-
# Process registry-specific templates
|
|
236
|
-
reg_created, reg_skipped, reg_warnings, reg_hashes = process_mappings(
|
|
237
|
-
reg.get_template_dir(),
|
|
238
|
-
reg.get_template_mappings(),
|
|
239
|
-
vars_dict,
|
|
240
|
-
force,
|
|
241
|
-
update,
|
|
242
|
-
existing_hashes,
|
|
243
|
-
)
|
|
244
|
-
|
|
245
|
-
# Process shared templates (skip if another registry already handled them)
|
|
246
|
-
shared_created, shared_skipped, shared_warnings, shared_hashes = [], [], [], {}
|
|
247
|
-
if not flags.get("skip-shared"):
|
|
248
|
-
shared_created, shared_skipped, shared_warnings, shared_hashes = process_mappings(
|
|
249
|
-
reg.get_shared_template_dir(),
|
|
250
|
-
reg.get_shared_template_mappings(),
|
|
251
|
-
vars_dict,
|
|
252
|
-
force,
|
|
253
|
-
update,
|
|
254
|
-
existing_hashes,
|
|
255
|
-
)
|
|
256
|
-
|
|
257
222
|
# Make all shell scripts in scripts/ executable
|
|
258
223
|
scripts_dir = os.path.join(".", "scripts")
|
|
259
224
|
if os.path.isdir(scripts_dir):
|
|
@@ -282,16 +247,11 @@ def run_cmd(registry, args, flags):
|
|
|
282
247
|
|
|
283
248
|
# Persist file hashes for future --update customization detection
|
|
284
249
|
all_new_hashes = {}
|
|
285
|
-
|
|
286
|
-
|
|
250
|
+
for h in all_hash_dicts:
|
|
251
|
+
all_new_hashes.update(h)
|
|
287
252
|
existing_hashes.update(all_new_hashes)
|
|
288
253
|
save_hashes(existing_hashes)
|
|
289
254
|
|
|
290
|
-
# Merge results
|
|
291
|
-
created = reg_created + shared_created
|
|
292
|
-
skipped = reg_skipped + shared_skipped
|
|
293
|
-
warnings = reg_warnings + shared_warnings
|
|
294
|
-
|
|
295
255
|
# Print summary
|
|
296
256
|
if created:
|
|
297
257
|
print("Created:")
|
|
@@ -314,8 +274,139 @@ def run_cmd(registry, args, flags):
|
|
|
314
274
|
print("\nNote: Existing CI workflow preserved. Review and merge manually if needed.")
|
|
315
275
|
|
|
316
276
|
# Next steps
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
277
|
+
if registry:
|
|
278
|
+
steps = NEXT_STEPS.get(registry)
|
|
279
|
+
if steps:
|
|
280
|
+
print("\nNext steps:")
|
|
281
|
+
for i, step in enumerate(steps, 1):
|
|
282
|
+
print(f" {i}. {step}")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def run_cmd(registry, args, flags):
|
|
286
|
+
"""Init command handler.
|
|
287
|
+
|
|
288
|
+
Scaffolds release infrastructure (CI, publish workflows, changelog, etc.)
|
|
289
|
+
from templates.
|
|
290
|
+
"""
|
|
291
|
+
reg = REGISTRIES[registry]
|
|
292
|
+
|
|
293
|
+
# Check that a project file exists
|
|
294
|
+
if not reg.check_project_exists("."):
|
|
295
|
+
print(f"Error: no {registry} project found in current directory.", file=sys.stderr)
|
|
296
|
+
print(reg.get_project_init_hint(), file=sys.stderr)
|
|
297
|
+
sys.exit(1)
|
|
298
|
+
|
|
299
|
+
# Gather template variables
|
|
300
|
+
vars_dict = reg.get_template_vars(".")
|
|
301
|
+
from datetime import datetime
|
|
302
|
+
vars_dict["year"] = str(datetime.now().year)
|
|
303
|
+
|
|
304
|
+
force = flags.get("force", False)
|
|
305
|
+
update = flags.get("update", False)
|
|
306
|
+
|
|
307
|
+
existing_hashes = load_hashes()
|
|
308
|
+
|
|
309
|
+
# Process registry-specific templates
|
|
310
|
+
reg_created, reg_skipped, reg_warnings, reg_hashes = process_mappings(
|
|
311
|
+
reg.get_template_dir(),
|
|
312
|
+
reg.get_template_mappings(),
|
|
313
|
+
vars_dict,
|
|
314
|
+
force,
|
|
315
|
+
update,
|
|
316
|
+
existing_hashes,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Process shared templates (skip if another registry already handled them)
|
|
320
|
+
shared_created, shared_skipped, shared_warnings, shared_hashes = [], [], [], {}
|
|
321
|
+
if not flags.get("skip-shared"):
|
|
322
|
+
shared_created, shared_skipped, shared_warnings, shared_hashes = process_mappings(
|
|
323
|
+
reg.get_shared_template_dir(),
|
|
324
|
+
reg.get_shared_template_mappings(),
|
|
325
|
+
vars_dict,
|
|
326
|
+
force,
|
|
327
|
+
update,
|
|
328
|
+
existing_hashes,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
created = reg_created + shared_created
|
|
332
|
+
skipped = reg_skipped + shared_skipped
|
|
333
|
+
warnings = reg_warnings + shared_warnings
|
|
334
|
+
|
|
335
|
+
_finalize_scaffold(
|
|
336
|
+
existing_hashes, [reg_hashes, shared_hashes],
|
|
337
|
+
created, skipped, warnings, registry=registry,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def run_cmd_multi(registries_list, args, flags):
|
|
342
|
+
"""Scaffold for multiple registries with a merged publish workflow.
|
|
343
|
+
|
|
344
|
+
Uses the primary registry for template vars and CI, then writes a merged
|
|
345
|
+
publish.yml that contains jobs for all detected registries.
|
|
346
|
+
"""
|
|
347
|
+
primary = registries_list[0]
|
|
348
|
+
reg = REGISTRIES[primary]
|
|
349
|
+
|
|
350
|
+
if not reg.check_project_exists("."):
|
|
351
|
+
print(f"Error: no {primary} project found in current directory.", file=sys.stderr)
|
|
352
|
+
sys.exit(1)
|
|
353
|
+
|
|
354
|
+
print(f"Multiple registries detected: {', '.join(registries_list)}")
|
|
355
|
+
print("Scaffolding with merged publish workflow.")
|
|
356
|
+
|
|
357
|
+
vars_dict = reg.get_template_vars(".")
|
|
358
|
+
from datetime import datetime
|
|
359
|
+
vars_dict["year"] = str(datetime.now().year)
|
|
360
|
+
|
|
361
|
+
force = flags.get("force", False)
|
|
362
|
+
update = flags.get("update", False)
|
|
363
|
+
existing_hashes = load_hashes()
|
|
364
|
+
|
|
365
|
+
# Process primary registry CI template only (publish will come from merged)
|
|
366
|
+
ci_mappings = [m for m in reg.get_template_mappings() if "publish" not in m["template"]]
|
|
367
|
+
ci_created, ci_skipped, ci_warnings, ci_hashes = process_mappings(
|
|
368
|
+
reg.get_template_dir(),
|
|
369
|
+
ci_mappings,
|
|
370
|
+
vars_dict,
|
|
371
|
+
force,
|
|
372
|
+
update,
|
|
373
|
+
existing_hashes,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
# Process merged publish workflow template
|
|
377
|
+
merged_tpl_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)),
|
|
378
|
+
"..", "templates", "merged")
|
|
379
|
+
merged_created, merged_skipped, merged_warnings, merged_hashes = process_mappings(
|
|
380
|
+
merged_tpl_dir,
|
|
381
|
+
[{"template": "publish.yml.tpl", "target": ".github/workflows/publish.yml"}],
|
|
382
|
+
vars_dict,
|
|
383
|
+
force,
|
|
384
|
+
update,
|
|
385
|
+
existing_hashes,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# Process shared templates (once)
|
|
389
|
+
shared_created, shared_skipped, shared_warnings, shared_hashes = process_mappings(
|
|
390
|
+
reg.get_shared_template_dir(),
|
|
391
|
+
reg.get_shared_template_mappings(),
|
|
392
|
+
vars_dict,
|
|
393
|
+
force,
|
|
394
|
+
update,
|
|
395
|
+
existing_hashes,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
created = ci_created + merged_created + shared_created
|
|
399
|
+
skipped = ci_skipped + merged_skipped + shared_skipped
|
|
400
|
+
warnings = ci_warnings + merged_warnings + shared_warnings
|
|
401
|
+
|
|
402
|
+
_finalize_scaffold(
|
|
403
|
+
existing_hashes, [ci_hashes, merged_hashes, shared_hashes],
|
|
404
|
+
created, skipped, warnings,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# Show combined next steps for dual-registry
|
|
408
|
+
print("\nNext steps:")
|
|
409
|
+
print(" 1. Add an NPM_TOKEN secret to your GitHub repo (Settings > Secrets > Actions)")
|
|
410
|
+
print(" 2. Configure Trusted Publishing on pypi.org")
|
|
411
|
+
print(" 3. Push to GitHub to activate the CI workflow")
|
|
412
|
+
print(" 4. Run rlsbl release [patch|minor|major]")
|
|
@@ -120,37 +120,71 @@ def run_cmd(registry, args, flags):
|
|
|
120
120
|
if name == registry:
|
|
121
121
|
continue
|
|
122
122
|
if other_reg.check_project_exists("."):
|
|
123
|
-
|
|
123
|
+
other_file = other_reg.get_version_file()
|
|
124
|
+
if other_file:
|
|
125
|
+
other_files.append(other_file)
|
|
124
126
|
if other_files:
|
|
125
127
|
log(f"Sync to: {', '.join(other_files)}")
|
|
126
128
|
log(f"Changelog:\n{changelog_entry}")
|
|
127
129
|
log("--- No changes made ---")
|
|
128
130
|
return
|
|
129
131
|
|
|
130
|
-
#
|
|
132
|
+
# Pre-compute which files will be modified
|
|
131
133
|
version_file = reg.get_version_file()
|
|
132
|
-
|
|
133
|
-
|
|
134
|
+
files_to_commit = []
|
|
135
|
+
if version_file:
|
|
136
|
+
files_to_commit.append(version_file)
|
|
137
|
+
for name, other_reg in REGISTRIES.items():
|
|
138
|
+
if name == registry:
|
|
139
|
+
continue
|
|
140
|
+
if other_reg.check_project_exists("."):
|
|
141
|
+
other_file = other_reg.get_version_file()
|
|
142
|
+
if other_file:
|
|
143
|
+
files_to_commit.append(other_file)
|
|
144
|
+
|
|
145
|
+
# Confirmation prompt (skip with --yes)
|
|
146
|
+
if not flags.get("yes"):
|
|
147
|
+
print(f"\nAbout to release {new_version} ({bump_type}) on {branch}")
|
|
148
|
+
print(f" Tag: {tag}")
|
|
149
|
+
if files_to_commit:
|
|
150
|
+
print(f" Files: {', '.join(files_to_commit)}")
|
|
151
|
+
else:
|
|
152
|
+
print(" Files: (none -- version is the git tag)")
|
|
153
|
+
try:
|
|
154
|
+
answer = input("Proceed? [y/N] ").strip().lower()
|
|
155
|
+
except (EOFError, KeyboardInterrupt):
|
|
156
|
+
print("\nAborted.")
|
|
157
|
+
sys.exit(1)
|
|
158
|
+
if answer != "y":
|
|
159
|
+
print("Aborted.")
|
|
160
|
+
sys.exit(0)
|
|
161
|
+
|
|
162
|
+
# Write new version to the primary registry file
|
|
163
|
+
if version_file:
|
|
164
|
+
reg.write_version(".", new_version)
|
|
165
|
+
log(f"Updated version in {version_file}")
|
|
134
166
|
|
|
135
167
|
# Sync version to all other recognized version files
|
|
136
|
-
files_to_commit = [version_file]
|
|
137
168
|
for name, other_reg in REGISTRIES.items():
|
|
138
169
|
if name == registry:
|
|
139
170
|
continue
|
|
140
171
|
if other_reg.check_project_exists("."):
|
|
141
|
-
other_reg.write_version(".", new_version)
|
|
142
172
|
other_file = other_reg.get_version_file()
|
|
143
|
-
|
|
144
|
-
|
|
173
|
+
if other_file:
|
|
174
|
+
other_reg.write_version(".", new_version)
|
|
175
|
+
log(f"Synced version to {other_file}")
|
|
145
176
|
|
|
146
177
|
# Commit all bumped version files together
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
178
|
+
if files_to_commit:
|
|
179
|
+
commit_tool = find_commit_tool()
|
|
180
|
+
if commit_tool == "safegit":
|
|
181
|
+
run(commit_tool, ["commit", "-m", tag, "--", *files_to_commit])
|
|
182
|
+
else:
|
|
183
|
+
run("git", ["add", *files_to_commit])
|
|
184
|
+
run("git", ["commit", "-m", tag])
|
|
185
|
+
log(f"Committed: {tag}")
|
|
150
186
|
else:
|
|
151
|
-
|
|
152
|
-
run("git", ["commit", "-m", tag])
|
|
153
|
-
log(f"Committed: {tag}")
|
|
187
|
+
log("No version files to commit (version is the git tag)")
|
|
154
188
|
|
|
155
189
|
# Create local git tag
|
|
156
190
|
run("git", ["tag", tag])
|
package/rlsbl/commands/status.py
CHANGED
|
@@ -33,7 +33,7 @@ def run_cmd(registry, args, flags):
|
|
|
33
33
|
for r_name, r_mod in REGISTRIES.items():
|
|
34
34
|
if r_mod.check_project_exists("."):
|
|
35
35
|
ver = r_mod.read_version(".")
|
|
36
|
-
file = r_mod.get_version_file()
|
|
36
|
+
file = r_mod.get_version_file() or "git tag"
|
|
37
37
|
print(f"Version: {ver} ({r_name}, {file})")
|
|
38
38
|
|
|
39
39
|
# Git info
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Undo command: revert the last release."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from ..utils import run, run_silent, check_gh_installed, check_gh_auth
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run_cmd(registry, args, flags):
|
|
9
|
+
check_gh_installed()
|
|
10
|
+
check_gh_auth()
|
|
11
|
+
|
|
12
|
+
# Find the latest tag
|
|
13
|
+
try:
|
|
14
|
+
tag = run("git", ["describe", "--tags", "--abbrev=0"])
|
|
15
|
+
except Exception:
|
|
16
|
+
print("Error: no tags found. Nothing to undo.", file=sys.stderr)
|
|
17
|
+
sys.exit(1)
|
|
18
|
+
|
|
19
|
+
print(f"This will undo release {tag}:")
|
|
20
|
+
print(f" - Delete git tag {tag} (local + remote)")
|
|
21
|
+
print(f" - Revert the version bump commit")
|
|
22
|
+
print(f" - Delete the GitHub Release for {tag}")
|
|
23
|
+
|
|
24
|
+
if not flags.get("yes"):
|
|
25
|
+
try:
|
|
26
|
+
answer = input("\nThis is destructive. Proceed? [y/N] ").strip().lower()
|
|
27
|
+
except (EOFError, KeyboardInterrupt):
|
|
28
|
+
print("\nAborted.")
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
if answer != "y":
|
|
31
|
+
print("Aborted.")
|
|
32
|
+
sys.exit(0)
|
|
33
|
+
|
|
34
|
+
# Delete GitHub Release
|
|
35
|
+
try:
|
|
36
|
+
run("gh", ["release", "delete", tag, "--yes"])
|
|
37
|
+
print(f"Deleted GitHub Release: {tag}")
|
|
38
|
+
except Exception as e:
|
|
39
|
+
print(f"Warning: could not delete GitHub Release: {e}")
|
|
40
|
+
|
|
41
|
+
# Delete remote tag
|
|
42
|
+
try:
|
|
43
|
+
run("git", ["push", "origin", f":{tag}"])
|
|
44
|
+
print(f"Deleted remote tag: {tag}")
|
|
45
|
+
except Exception as e:
|
|
46
|
+
print(f"Warning: could not delete remote tag: {e}")
|
|
47
|
+
|
|
48
|
+
# Delete local tag
|
|
49
|
+
try:
|
|
50
|
+
run("git", ["tag", "-d", tag])
|
|
51
|
+
print(f"Deleted local tag: {tag}")
|
|
52
|
+
except Exception as e:
|
|
53
|
+
print(f"Warning: could not delete local tag: {e}")
|
|
54
|
+
|
|
55
|
+
# Revert the version bump commit (should be HEAD)
|
|
56
|
+
try:
|
|
57
|
+
head_msg = run("git", ["log", "-1", "--format=%s"])
|
|
58
|
+
if head_msg == tag:
|
|
59
|
+
run("git", ["revert", "--no-edit", "HEAD"])
|
|
60
|
+
print(f"Reverted commit: {head_msg}")
|
|
61
|
+
else:
|
|
62
|
+
print(f"Warning: HEAD commit ({head_msg}) doesn't match tag ({tag}). Skipping revert.")
|
|
63
|
+
except Exception as e:
|
|
64
|
+
print(f"Warning: could not revert commit: {e}")
|
|
65
|
+
|
|
66
|
+
print(f"\nUndo complete. Run 'git push' to sync the revert.")
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Go registry adapter for rlsbl.
|
|
2
|
+
|
|
3
|
+
Go modules are versioned by git tags, not version files. GoReleaser handles
|
|
4
|
+
the build/publish step. rlsbl's role: changelog validation, tagging, GitHub
|
|
5
|
+
Release creation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from ..utils import run
|
|
12
|
+
|
|
13
|
+
NAME = "go"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def read_version(dir_path):
|
|
17
|
+
"""Read version from the latest git tag.
|
|
18
|
+
|
|
19
|
+
Go modules have no version file -- the version IS the git tag.
|
|
20
|
+
Returns "0.0.0" if no tags exist yet.
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
tag = run("git", ["describe", "--tags", "--abbrev=0"])
|
|
24
|
+
return tag.lstrip("v")
|
|
25
|
+
except Exception:
|
|
26
|
+
return "0.0.0"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_version(dir_path, version):
|
|
30
|
+
"""No-op: Go versions are git tags, not file fields."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_version_file():
|
|
35
|
+
"""Go has no version file -- version is the git tag."""
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_template_dir():
|
|
40
|
+
"""Returns path to the go-specific template directory."""
|
|
41
|
+
return os.path.join(os.path.dirname(__file__), "..", "..", "templates", "go")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_shared_template_dir():
|
|
45
|
+
"""Returns path to the shared template directory."""
|
|
46
|
+
return os.path.join(os.path.dirname(__file__), "..", "..", "templates", "shared")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_template_vars(dir_path):
|
|
50
|
+
"""Extract template variables from go.mod."""
|
|
51
|
+
mod_path = os.path.join(dir_path, "go.mod")
|
|
52
|
+
name = ""
|
|
53
|
+
if os.path.exists(mod_path):
|
|
54
|
+
with open(mod_path) as f:
|
|
55
|
+
content = f.read()
|
|
56
|
+
match = re.search(r"^module\s+(\S+)", content, re.MULTILINE)
|
|
57
|
+
if match:
|
|
58
|
+
name = match.group(1)
|
|
59
|
+
|
|
60
|
+
# Derive short name from module path (last segment)
|
|
61
|
+
short_name = name.rsplit("/", 1)[-1] if "/" in name else name
|
|
62
|
+
|
|
63
|
+
# Derive repo name from module path (e.g. "github.com/user/repo")
|
|
64
|
+
repo_name = ""
|
|
65
|
+
repo_match = re.search(r"github\.com/([^/\s]+/[^/\s]+)", name)
|
|
66
|
+
if repo_match:
|
|
67
|
+
repo_name = repo_match.group(1)
|
|
68
|
+
|
|
69
|
+
# Author from git config
|
|
70
|
+
author = ""
|
|
71
|
+
try:
|
|
72
|
+
author = run("git", ["config", "user.name"])
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
version = read_version(dir_path)
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
"name": short_name,
|
|
80
|
+
"modulePath": name,
|
|
81
|
+
"version": version,
|
|
82
|
+
"author": author,
|
|
83
|
+
"repoName": repo_name,
|
|
84
|
+
"binCommand": short_name,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_template_mappings():
|
|
89
|
+
"""Returns go-specific template mappings (template file -> target path)."""
|
|
90
|
+
return [
|
|
91
|
+
{"template": "ci.yml.tpl", "target": ".github/workflows/ci.yml"},
|
|
92
|
+
{"template": "publish.yml.tpl", "target": ".github/workflows/publish.yml"},
|
|
93
|
+
{"template": "goreleaser.yml.tpl", "target": ".goreleaser.yml"},
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_shared_template_mappings():
|
|
98
|
+
"""Returns shared template mappings."""
|
|
99
|
+
return [
|
|
100
|
+
{"template": "CHANGELOG.md.tpl", "target": "CHANGELOG.md"},
|
|
101
|
+
{"template": "gitignore.tpl", "target": ".gitignore"},
|
|
102
|
+
{"template": "LICENSE.tpl", "target": "LICENSE"},
|
|
103
|
+
{"template": "CLAUDE.md.tpl", "target": "CLAUDE.md"},
|
|
104
|
+
{"template": "check-prs.sh.tpl", "target": "scripts/check-prs.sh"},
|
|
105
|
+
{"template": "claude-settings.json.tpl", "target": ".claude/settings.json"},
|
|
106
|
+
{"template": "record-gif.sh.tpl", "target": "scripts/record-gif.sh"},
|
|
107
|
+
{"template": "pre-release.sh.tpl", "target": "scripts/pre-release.sh"},
|
|
108
|
+
{"template": "pre-push-hook.sh.tpl", "target": "scripts/pre-push-hook.sh"},
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def check_project_exists(dir_path):
|
|
113
|
+
"""Returns True if a go.mod exists in the given directory."""
|
|
114
|
+
return os.path.exists(os.path.join(dir_path, "go.mod"))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_project_init_hint():
|
|
118
|
+
"""Hint for users who haven't initialized their project yet."""
|
|
119
|
+
return 'Run "go mod init <module-path>" first'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
go-version: ["1.22", "1.23", "1.24"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v5
|
|
17
|
+
- uses: actions/setup-go@v5
|
|
18
|
+
with:
|
|
19
|
+
go-version: ${{ matrix.go-version }}
|
|
20
|
+
- run: go test ./...
|
|
21
|
+
- run: go vet ./...
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
|
|
3
|
+
builds:
|
|
4
|
+
- env:
|
|
5
|
+
- CGO_ENABLED=0
|
|
6
|
+
goos:
|
|
7
|
+
- linux
|
|
8
|
+
- darwin
|
|
9
|
+
- windows
|
|
10
|
+
goarch:
|
|
11
|
+
- amd64
|
|
12
|
+
- arm64
|
|
13
|
+
|
|
14
|
+
archives:
|
|
15
|
+
- format: tar.gz
|
|
16
|
+
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
|
17
|
+
format_overrides:
|
|
18
|
+
- goos: windows
|
|
19
|
+
format: zip
|
|
20
|
+
|
|
21
|
+
checksum:
|
|
22
|
+
name_template: checksums.txt
|
|
23
|
+
|
|
24
|
+
changelog:
|
|
25
|
+
use: github-native
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: write
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
goreleaser:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v5
|
|
15
|
+
with:
|
|
16
|
+
fetch-depth: 0
|
|
17
|
+
- uses: actions/setup-go@v5
|
|
18
|
+
with:
|
|
19
|
+
go-version-file: go.mod
|
|
20
|
+
- uses: goreleaser/goreleaser-action@v6
|
|
21
|
+
with:
|
|
22
|
+
version: latest
|
|
23
|
+
args: release --clean
|
|
24
|
+
env:
|
|
25
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
id-token: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
npm:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v5
|
|
16
|
+
- uses: actions/setup-node@v5
|
|
17
|
+
with:
|
|
18
|
+
node-version: 22
|
|
19
|
+
registry-url: https://registry.npmjs.org
|
|
20
|
+
- run: npm publish --provenance --access public
|
|
21
|
+
env:
|
|
22
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
23
|
+
|
|
24
|
+
pypi:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v5
|
|
28
|
+
- uses: astral-sh/setup-uv@v7
|
|
29
|
+
- run: uv build
|
|
30
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|