rlsbl 0.1.1 → 0.3.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/README.md CHANGED
@@ -25,7 +25,7 @@ rlsbl release minor
25
25
 
26
26
  ## Commands
27
27
 
28
- All commands work at the top level -- registries are auto-detected from project files (`package.json`, `pyproject.toml`). Use the registry-specific form (`rlsbl <registry> <command>`) only when you need to target a single registry.
28
+ All commands work at the top level -- registries are auto-detected from project files (`package.json`, `pyproject.toml`). Use `--registry <npm|pypi>` when you need to target a specific registry.
29
29
 
30
30
  ### scaffold [--force] [--update]
31
31
 
@@ -33,8 +33,8 @@ Scaffolds CI/CD infrastructure and release tooling for all detected registries.
33
33
 
34
34
  ```
35
35
  rlsbl scaffold
36
- rlsbl npm scaffold # target npm only
37
- rlsbl pypi scaffold --force # overwrite existing files
36
+ rlsbl scaffold --registry npm # target npm only
37
+ rlsbl scaffold --registry pypi --force # overwrite existing files
38
38
  ```
39
39
 
40
40
  Context-aware behavior when files already exist (without `--force`):
@@ -52,7 +52,7 @@ Bumps version, commits, pushes, and creates a GitHub Release. Defaults to `patch
52
52
 
53
53
  ```
54
54
  rlsbl release minor
55
- rlsbl npm release major --dry-run
55
+ rlsbl release major --dry-run --registry npm
56
56
  ```
57
57
 
58
58
  The version is synced across all detected project files (`package.json`, `pyproject.toml`) regardless of which registry is primary.
@@ -65,16 +65,16 @@ Shows project status: package name, version (per registry), git branch, last tag
65
65
 
66
66
  ```
67
67
  rlsbl status
68
- rlsbl pypi status
68
+ rlsbl status --registry pypi
69
69
  ```
70
70
 
71
- ### check-name \<name\>
71
+ ### check \<name\>
72
72
 
73
73
  Checks name availability on both npm and PyPI, and warns about confusingly similar names.
74
74
 
75
75
  ```
76
- rlsbl check-name my-cool-lib
77
- rlsbl npm check-name my-cool-lib # npm only
76
+ rlsbl check my-cool-lib
77
+ rlsbl check my-cool-lib --registry npm # npm only
78
78
  ```
79
79
 
80
80
  npm checks variant spellings (hyphens, underscores, dots, no separator). PyPI normalizes per PEP 503 and checks common alternatives.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rlsbl",
3
- "version": "0.1.1",
3
+ "version": "0.3.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
@@ -10,22 +10,24 @@ except Exception:
10
10
  __version__ = "unknown"
11
11
 
12
12
  REGISTRIES = ("npm", "pypi")
13
- COMMANDS = ("release", "status", "scaffold", "check-name")
13
+ COMMANDS = ("release", "status", "scaffold", "check", "config", "undo")
14
14
  COMMAND_ALIASES = {"init": "scaffold"}
15
15
 
16
16
  HELP = f"""\
17
17
  rlsbl v{__version__} -- Release orchestration and project scaffolding for npm and PyPI
18
18
 
19
19
  Usage:
20
- rlsbl release [patch|minor|major] [--dry-run] [--quiet] Orchestrate a release
21
- rlsbl status Show project status
20
+ rlsbl release [patch|minor|major] [--dry-run] [--yes] [--quiet] Orchestrate a release
21
+ rlsbl status Show project status
22
22
  rlsbl scaffold [--force] [--update] Scaffold release infrastructure
23
- rlsbl check-name <name> Check name availability
23
+ rlsbl check <name> Check name availability
24
+ rlsbl config Show project configuration
25
+ rlsbl undo [--yes] Revert the last release
24
26
 
25
- Registry-specific (when you need to target one):
26
- rlsbl <registry> <command> [args...]
27
-
28
- Registries: {', '.join(REGISTRIES)}"""
27
+ Options:
28
+ --registry <npm|pypi> Target a specific registry (auto-detected if omitted)
29
+ --help, -h Show this help
30
+ --version, -v Show version"""
29
31
 
30
32
 
31
33
  def detect_registries():
@@ -42,19 +44,33 @@ def detect_registries():
42
44
 
43
45
 
44
46
  def parse_args(argv):
45
- """Parse sys.argv into positional args and flags."""
47
+ """Parse sys.argv into positional args and flags.
48
+
49
+ Flags listed in VALUE_FLAGS consume the next token as their value
50
+ (e.g. --registry npm). All other --flags are boolean.
51
+ """
52
+ VALUE_FLAGS = ("registry",)
46
53
  raw = argv[1:]
47
54
  positional = []
48
55
  flags = {}
49
-
50
- for arg in raw:
56
+ i = 0
57
+ while i < len(raw):
58
+ arg = raw[i]
51
59
  if arg.startswith("--"):
52
- flags[arg[2:]] = True
60
+ key = arg[2:]
61
+ if "=" in key:
62
+ k, v = key.split("=", 1)
63
+ flags[k] = v
64
+ elif key in VALUE_FLAGS and i + 1 < len(raw) and not raw[i + 1].startswith("-"):
65
+ flags[key] = raw[i + 1]
66
+ i += 1
67
+ else:
68
+ flags[key] = True
53
69
  elif arg.startswith("-") and len(arg) == 2:
54
70
  flags[arg[1:]] = True
55
71
  else:
56
72
  positional.append(arg)
57
-
73
+ i += 1
58
74
  return positional, flags
59
75
 
60
76
 
@@ -65,7 +81,9 @@ def _get_command_module(command):
65
81
  "release": "release",
66
82
  "status": "status",
67
83
  "scaffold": "init_cmd",
68
- "check-name": "check_name",
84
+ "check": "check",
85
+ "config": "config",
86
+ "undo": "undo",
69
87
  }
70
88
  module_name = module_map.get(command)
71
89
  if not module_name:
@@ -88,49 +106,31 @@ def main():
88
106
  print(__version__)
89
107
  sys.exit(0)
90
108
 
91
- first = positional[0] if positional else None
109
+ command = positional[0] if positional else None
92
110
 
93
111
  # Resolve command aliases (e.g. "init" -> "scaffold")
94
- if first in COMMAND_ALIASES:
95
- first = COMMAND_ALIASES[first]
112
+ if command in COMMAND_ALIASES:
113
+ command = COMMAND_ALIASES[command]
96
114
 
97
- if not first:
115
+ if not command:
98
116
  print("Error: missing command.\n", file=sys.stderr)
99
117
  print(HELP, file=sys.stderr)
100
118
  sys.exit(1)
101
119
 
102
- registry = None
103
- command = None
104
- args = []
105
-
106
- if first in COMMANDS:
107
- # Top-level: rlsbl <command> ... -- auto-detect registry
108
- command = first
109
- args = positional[1:]
110
- elif first in REGISTRIES:
111
- # Registry-prefixed: rlsbl <registry> <command> ...
112
- registry = first
113
- command = positional[1] if len(positional) > 1 else None
114
- if command and command in COMMAND_ALIASES:
115
- command = COMMAND_ALIASES[command]
116
-
117
- if not command:
118
- print(f'Error: missing command for registry "{registry}".\n', file=sys.stderr)
119
- print(HELP, file=sys.stderr)
120
- sys.exit(1)
120
+ if command not in COMMANDS:
121
+ print(f'Error: unknown command "{command}".\n', file=sys.stderr)
122
+ print(HELP, file=sys.stderr)
123
+ sys.exit(1)
121
124
 
122
- if command not in COMMANDS:
123
- print(
124
- f'Error: unknown command "{command}". Valid commands: {", ".join(COMMANDS)}\n',
125
- file=sys.stderr,
126
- )
127
- print(HELP, file=sys.stderr)
128
- sys.exit(1)
125
+ args = positional[1:]
126
+ registry = flags.get("registry")
129
127
 
130
- args = positional[2:]
131
- else:
132
- print(f'Error: unknown command or registry "{first}".\n', file=sys.stderr)
133
- print(HELP, file=sys.stderr)
128
+ # Validate --registry if provided
129
+ if registry and registry not in REGISTRIES:
130
+ print(
131
+ f"Error: unknown registry '{registry}'. Valid: {', '.join(REGISTRIES)}",
132
+ file=sys.stderr,
133
+ )
134
134
  sys.exit(1)
135
135
 
136
136
  try:
@@ -139,36 +139,49 @@ def main():
139
139
  print(f'Error: command "{command}" is not yet implemented.', file=sys.stderr)
140
140
  sys.exit(1)
141
141
 
142
- if registry:
143
- # Explicit registry -- single invocation
144
- handler.run_cmd(registry, args, flags)
145
- elif command == "check-name":
146
- # Top-level check-name: check ALL registries
147
- regs = ["npm", "pypi"]
148
- for i, r in enumerate(regs):
149
- handler.run_cmd(r, args, flags)
150
- if i < len(regs) - 1:
151
- print("")
142
+ if command == "check":
143
+ # check: if registry given, check that one; otherwise check all
144
+ if registry:
145
+ handler.run_cmd(registry, args, flags)
146
+ else:
147
+ for i, r in enumerate(["npm", "pypi"]):
148
+ handler.run_cmd(r, args, flags)
149
+ if i < 1:
150
+ print("")
152
151
  elif command == "scaffold":
153
- # Top-level scaffold: scaffold for each detected registry
152
+ if registry:
153
+ handler.run_cmd(registry, args, flags)
154
+ else:
155
+ regs = detect_registries()
156
+ if not regs:
157
+ print("Error: no package.json or pyproject.toml found.", file=sys.stderr)
158
+ sys.exit(1)
159
+ if len(regs) > 1:
160
+ handler.run_cmd_multi(regs, args, flags)
161
+ else:
162
+ handler.run_cmd(regs[0], args, flags)
163
+ elif command == "config":
164
+ # config: auto-detect, pass first registry or fallback
154
165
  regs = detect_registries()
155
- if not regs:
156
- print("Error: no package.json or pyproject.toml found.", file=sys.stderr)
157
- sys.exit(1)
158
- if len(regs) > 1:
159
- # Multi-registry: only scaffold for the primary registry
160
- # (CI/publish workflows conflict when both registries target the same paths)
161
- print(f"Multiple registries detected: {', '.join(regs)}")
162
- print(f"Scaffolding for primary registry: {regs[0]}")
163
- print("For dual-registry projects, manually configure workflows with both jobs.")
164
- handler.run_cmd(regs[0], args, flags)
166
+ handler.run_cmd(registry or (regs[0] if regs else "npm"), args, flags)
167
+ elif command == "undo":
168
+ # undo: auto-detect like release
169
+ if not registry:
170
+ regs = detect_registries()
171
+ if not regs:
172
+ print("Error: no package.json or pyproject.toml found.", file=sys.stderr)
173
+ sys.exit(1)
174
+ registry = regs[0]
175
+ handler.run_cmd(registry, args, flags)
165
176
  else:
166
- # Top-level release/status: use primary detected registry
167
- regs = detect_registries()
168
- if not regs:
169
- print("Error: no package.json or pyproject.toml found.", file=sys.stderr)
170
- sys.exit(1)
171
- handler.run_cmd(regs[0], args, flags)
177
+ # release, status: use explicit registry or auto-detect primary
178
+ if not registry:
179
+ regs = detect_registries()
180
+ if not regs:
181
+ print("Error: no package.json or pyproject.toml found.", file=sys.stderr)
182
+ sys.exit(1)
183
+ registry = regs[0]
184
+ handler.run_cmd(registry, args, flags)
172
185
  except Exception as e:
173
186
  print(f"Error: {e}", file=sys.stderr)
174
187
  sys.exit(1)
@@ -1,4 +1,4 @@
1
- """Check-name command: check package name availability on npm or PyPI."""
1
+ """Check command: check package name availability on npm or PyPI."""
2
2
 
3
3
  import re
4
4
  import subprocess
@@ -166,14 +166,14 @@ def _check_name_pypi(name):
166
166
 
167
167
 
168
168
  def run_cmd(registry, args, flags):
169
- """Check-name command handler.
169
+ """Check command handler.
170
170
 
171
171
  Checks package name availability on npm or PyPI, and warns about similar names.
172
172
  """
173
173
  name = args[0] if args else None
174
174
  if not name:
175
175
  print(
176
- "Error: missing package name. Usage: rlsbl check-name <name>",
176
+ "Error: missing package name. Usage: rlsbl check <name>",
177
177
  file=sys.stderr,
178
178
  )
179
179
  sys.exit(1)
@@ -0,0 +1,48 @@
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
+ print(f" {name}: {reg.get_version_file()} (v{version})")
13
+ else:
14
+ print(f" {name}: not found")
15
+
16
+ print("\nScaffolding:")
17
+ rlsbl_dir = os.path.join(".", ".rlsbl")
18
+ if os.path.isdir(rlsbl_dir):
19
+ version_file = os.path.join(rlsbl_dir, "version")
20
+ if os.path.exists(version_file):
21
+ with open(version_file) as f:
22
+ scaffold_ver = f.read().strip()
23
+ print(f" Version marker: {scaffold_ver}")
24
+ hashes_file = os.path.join(rlsbl_dir, "hashes.json")
25
+ if os.path.exists(hashes_file):
26
+ import json
27
+ with open(hashes_file) as f:
28
+ hashes = json.load(f)
29
+ print(f" Tracked files: {len(hashes)}")
30
+ for path in sorted(hashes):
31
+ print(f" {path}")
32
+ else:
33
+ print(" Not scaffolded (run 'rlsbl scaffold')")
34
+
35
+ print("\nWorkflows:")
36
+ for wf in ["ci.yml", "publish.yml", "workflow.yml"]:
37
+ path = os.path.join(".github", "workflows", wf)
38
+ print(f" {wf}: {'yes' if os.path.exists(path) else 'no'}")
39
+
40
+ print("\nHooks:")
41
+ pre_release = os.path.join("scripts", "pre-release.sh")
42
+ print(f" pre-release.sh: {'yes' if os.path.exists(pre_release) else 'no'}")
43
+ pre_push = os.path.join(".git", "hooks", "pre-push")
44
+ print(f" pre-push hook: {'installed' if os.path.exists(pre_push) else 'not installed'}")
45
+
46
+ print("\nFiles:")
47
+ for f in ["CHANGELOG.md", "LICENSE", ".gitignore", "CLAUDE.md"]:
48
+ print(f" {f}: {'yes' if os.path.exists(f) else 'no'}")
@@ -53,12 +53,12 @@ NEXT_STEPS = {
53
53
  "npm": [
54
54
  "Add an NPM_TOKEN secret to your GitHub repo (Settings > Secrets > Actions)",
55
55
  "Push to GitHub to activate the CI workflow",
56
- "Run rlsbl npm release [patch|minor|major]",
56
+ "Run rlsbl release [patch|minor|major]",
57
57
  ],
58
58
  "pypi": [
59
59
  "Push to GitHub",
60
60
  "Configure Trusted Publishing on pypi.org",
61
- "Run rlsbl pypi release [patch|minor|major]",
61
+ "Run rlsbl release [patch|minor|major]",
62
62
  ],
63
63
  }
64
64
 
@@ -208,52 +208,12 @@ def process_mappings(template_dir, mappings, vars_dict, force, update=False,
208
208
  return created, skipped, warnings, new_hashes
209
209
 
210
210
 
211
- def run_cmd(registry, args, flags):
212
- """Init command handler.
211
+ def _finalize_scaffold(existing_hashes, all_hash_dicts, created, skipped, warnings,
212
+ registry=None):
213
+ """Shared post-processing for scaffold: chmod, hooks, version marker, hashes, summary.
213
214
 
214
- Scaffolds release infrastructure (CI, publish workflows, changelog, etc.)
215
- from templates.
215
+ all_hash_dicts is a list of dicts to merge into existing_hashes.
216
216
  """
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
217
  # Make all shell scripts in scripts/ executable
258
218
  scripts_dir = os.path.join(".", "scripts")
259
219
  if os.path.isdir(scripts_dir):
@@ -282,16 +242,11 @@ def run_cmd(registry, args, flags):
282
242
 
283
243
  # Persist file hashes for future --update customization detection
284
244
  all_new_hashes = {}
285
- all_new_hashes.update(reg_hashes)
286
- all_new_hashes.update(shared_hashes)
245
+ for h in all_hash_dicts:
246
+ all_new_hashes.update(h)
287
247
  existing_hashes.update(all_new_hashes)
288
248
  save_hashes(existing_hashes)
289
249
 
290
- # Merge results
291
- created = reg_created + shared_created
292
- skipped = reg_skipped + shared_skipped
293
- warnings = reg_warnings + shared_warnings
294
-
295
250
  # Print summary
296
251
  if created:
297
252
  print("Created:")
@@ -314,8 +269,139 @@ def run_cmd(registry, args, flags):
314
269
  print("\nNote: Existing CI workflow preserved. Review and merge manually if needed.")
315
270
 
316
271
  # 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}")
272
+ if registry:
273
+ steps = NEXT_STEPS.get(registry)
274
+ if steps:
275
+ print("\nNext steps:")
276
+ for i, step in enumerate(steps, 1):
277
+ print(f" {i}. {step}")
278
+
279
+
280
+ def run_cmd(registry, args, flags):
281
+ """Init command handler.
282
+
283
+ Scaffolds release infrastructure (CI, publish workflows, changelog, etc.)
284
+ from templates.
285
+ """
286
+ reg = REGISTRIES[registry]
287
+
288
+ # Check that a project file exists
289
+ if not reg.check_project_exists("."):
290
+ print(f"Error: no {registry} project found in current directory.", file=sys.stderr)
291
+ print(reg.get_project_init_hint(), file=sys.stderr)
292
+ sys.exit(1)
293
+
294
+ # Gather template variables
295
+ vars_dict = reg.get_template_vars(".")
296
+ from datetime import datetime
297
+ vars_dict["year"] = str(datetime.now().year)
298
+
299
+ force = flags.get("force", False)
300
+ update = flags.get("update", False)
301
+
302
+ existing_hashes = load_hashes()
303
+
304
+ # Process registry-specific templates
305
+ reg_created, reg_skipped, reg_warnings, reg_hashes = process_mappings(
306
+ reg.get_template_dir(),
307
+ reg.get_template_mappings(),
308
+ vars_dict,
309
+ force,
310
+ update,
311
+ existing_hashes,
312
+ )
313
+
314
+ # Process shared templates (skip if another registry already handled them)
315
+ shared_created, shared_skipped, shared_warnings, shared_hashes = [], [], [], {}
316
+ if not flags.get("skip-shared"):
317
+ shared_created, shared_skipped, shared_warnings, shared_hashes = process_mappings(
318
+ reg.get_shared_template_dir(),
319
+ reg.get_shared_template_mappings(),
320
+ vars_dict,
321
+ force,
322
+ update,
323
+ existing_hashes,
324
+ )
325
+
326
+ created = reg_created + shared_created
327
+ skipped = reg_skipped + shared_skipped
328
+ warnings = reg_warnings + shared_warnings
329
+
330
+ _finalize_scaffold(
331
+ existing_hashes, [reg_hashes, shared_hashes],
332
+ created, skipped, warnings, registry=registry,
333
+ )
334
+
335
+
336
+ def run_cmd_multi(registries_list, args, flags):
337
+ """Scaffold for multiple registries with a merged publish workflow.
338
+
339
+ Uses the primary registry for template vars and CI, then writes a merged
340
+ publish.yml that contains jobs for all detected registries.
341
+ """
342
+ primary = registries_list[0]
343
+ reg = REGISTRIES[primary]
344
+
345
+ if not reg.check_project_exists("."):
346
+ print(f"Error: no {primary} project found in current directory.", file=sys.stderr)
347
+ sys.exit(1)
348
+
349
+ print(f"Multiple registries detected: {', '.join(registries_list)}")
350
+ print("Scaffolding with merged publish workflow.")
351
+
352
+ vars_dict = reg.get_template_vars(".")
353
+ from datetime import datetime
354
+ vars_dict["year"] = str(datetime.now().year)
355
+
356
+ force = flags.get("force", False)
357
+ update = flags.get("update", False)
358
+ existing_hashes = load_hashes()
359
+
360
+ # Process primary registry CI template only (publish will come from merged)
361
+ ci_mappings = [m for m in reg.get_template_mappings() if "publish" not in m["template"]]
362
+ ci_created, ci_skipped, ci_warnings, ci_hashes = process_mappings(
363
+ reg.get_template_dir(),
364
+ ci_mappings,
365
+ vars_dict,
366
+ force,
367
+ update,
368
+ existing_hashes,
369
+ )
370
+
371
+ # Process merged publish workflow template
372
+ merged_tpl_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)),
373
+ "..", "templates", "merged")
374
+ merged_created, merged_skipped, merged_warnings, merged_hashes = process_mappings(
375
+ merged_tpl_dir,
376
+ [{"template": "publish.yml.tpl", "target": ".github/workflows/publish.yml"}],
377
+ vars_dict,
378
+ force,
379
+ update,
380
+ existing_hashes,
381
+ )
382
+
383
+ # Process shared templates (once)
384
+ shared_created, shared_skipped, shared_warnings, shared_hashes = process_mappings(
385
+ reg.get_shared_template_dir(),
386
+ reg.get_shared_template_mappings(),
387
+ vars_dict,
388
+ force,
389
+ update,
390
+ existing_hashes,
391
+ )
392
+
393
+ created = ci_created + merged_created + shared_created
394
+ skipped = ci_skipped + merged_skipped + shared_skipped
395
+ warnings = ci_warnings + merged_warnings + shared_warnings
396
+
397
+ _finalize_scaffold(
398
+ existing_hashes, [ci_hashes, merged_hashes, shared_hashes],
399
+ created, skipped, warnings,
400
+ )
401
+
402
+ # Show combined next steps for dual-registry
403
+ print("\nNext steps:")
404
+ print(" 1. Add an NPM_TOKEN secret to your GitHub repo (Settings > Secrets > Actions)")
405
+ print(" 2. Configure Trusted Publishing on pypi.org")
406
+ print(" 3. Push to GitHub to activate the CI workflow")
407
+ print(" 4. Run rlsbl release [patch|minor|major]")
@@ -127,21 +127,40 @@ def run_cmd(registry, args, flags):
127
127
  log("--- No changes made ---")
128
128
  return
129
129
 
130
- # Write new version to the primary registry file
130
+ # Pre-compute which files will be modified
131
131
  version_file = reg.get_version_file()
132
+ files_to_commit = [version_file]
133
+ for name, other_reg in REGISTRIES.items():
134
+ if name == registry:
135
+ continue
136
+ if other_reg.check_project_exists("."):
137
+ files_to_commit.append(other_reg.get_version_file())
138
+
139
+ # Confirmation prompt (skip with --yes)
140
+ if not flags.get("yes"):
141
+ print(f"\nAbout to release {new_version} ({bump_type}) on {branch}")
142
+ print(f" Tag: {tag}")
143
+ print(f" Files: {', '.join(files_to_commit)}")
144
+ try:
145
+ answer = input("Proceed? [y/N] ").strip().lower()
146
+ except (EOFError, KeyboardInterrupt):
147
+ print("\nAborted.")
148
+ sys.exit(1)
149
+ if answer != "y":
150
+ print("Aborted.")
151
+ sys.exit(0)
152
+
153
+ # Write new version to the primary registry file
132
154
  reg.write_version(".", new_version)
133
155
  log(f"Updated version in {version_file}")
134
156
 
135
157
  # Sync version to all other recognized version files
136
- files_to_commit = [version_file]
137
158
  for name, other_reg in REGISTRIES.items():
138
159
  if name == registry:
139
160
  continue
140
161
  if other_reg.check_project_exists("."):
141
162
  other_reg.write_version(".", new_version)
142
- other_file = other_reg.get_version_file()
143
- files_to_commit.append(other_file)
144
- log(f"Synced version to {other_file}")
163
+ log(f"Synced version to {other_reg.get_version_file()}")
145
164
 
146
165
  # Commit all bumped version files together
147
166
  commit_tool = find_commit_tool()
@@ -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,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
@@ -5,11 +5,11 @@
5
5
  This project uses [rlsbl](https://github.com/smm-h/rlsbl) for release orchestration.
6
6
 
7
7
  - Update CHANGELOG.md with a `## X.Y.Z` entry describing changes
8
- - Run `rlsbl <registry> release [patch|minor|major]` to bump version and create a GitHub Release
8
+ - Run `rlsbl release [patch|minor|major]` to bump version and create a GitHub Release
9
9
  - CI handles publishing automatically via the publish workflow
10
- - Never publish manually — always use `rlsbl <registry> release`
10
+ - Never publish manually — always use `rlsbl release`
11
11
  - Requires `NPM_TOKEN` secret on GitHub (for npm projects)
12
- - Use `rlsbl <registry> release --dry-run` to preview a release without making changes
12
+ - Use `rlsbl release --dry-run` to preview a release without making changes
13
13
 
14
14
  ## Conventions
15
15