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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rlsbl",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Release orchestration and project scaffolding for npm and PyPI",
5
5
  "license": "MIT",
6
6
  "bin": {
package/rlsbl/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """rlsbl: Release orchestration and project scaffolding for npm and PyPI."""
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 PyPI
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
- for i, r in enumerate(["npm", "pypi"]):
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 or pyproject.toml found.", file=sys.stderr)
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
- print(f"Multiple registries detected: {', '.join(regs)}")
157
- print(f"Scaffolding for primary registry: {regs[0]}")
158
- print("For dual-registry projects, manually configure workflows with both jobs.")
159
- handler.run_cmd(regs[0], args, flags)
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 or pyproject.toml found.", file=sys.stderr)
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)
@@ -1,4 +1,4 @@
1
- """Check command: check package name availability on npm or PyPI."""
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 PyPI, and warns about similar names.
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
- else:
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 run_cmd(registry, args, flags):
212
- """Init command handler.
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
- Scaffolds release infrastructure (CI, publish workflows, changelog, etc.)
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
- all_new_hashes.update(reg_hashes)
286
- all_new_hashes.update(shared_hashes)
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
- steps = NEXT_STEPS.get(registry)
318
- if steps:
319
- print("\nNext steps:")
320
- for i, step in enumerate(steps, 1):
321
- print(f" {i}. {step}")
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
- other_files.append(other_reg.get_version_file())
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
- # Write new version to the primary registry file
132
+ # Pre-compute which files will be modified
131
133
  version_file = reg.get_version_file()
132
- reg.write_version(".", new_version)
133
- log(f"Updated version in {version_file}")
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
- files_to_commit.append(other_file)
144
- log(f"Synced version to {other_file}")
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
- commit_tool = find_commit_tool()
148
- if commit_tool == "safegit":
149
- run(commit_tool, ["commit", "-m", tag, "--", *files_to_commit])
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
- run("git", ["add", *files_to_commit])
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])
@@ -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.")
@@ -1,5 +1,5 @@
1
1
  """Registry lookup for rlsbl."""
2
2
 
3
- from . import npm, pypi
3
+ from . import go, npm, pypi
4
4
 
5
- REGISTRIES = {"npm": npm, "pypi": pypi}
5
+ REGISTRIES = {"npm": npm, "pypi": pypi, "go": go}
@@ -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