rlsbl 0.2.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rlsbl",
3
- "version": "0.2.0",
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,17 +10,19 @@ except Exception:
10
10
  __version__ = "unknown"
11
11
 
12
12
  REGISTRIES = ("npm", "pypi")
13
- COMMANDS = ("release", "status", "scaffold", "check")
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
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
28
  --registry <npm|pypi> Target a specific registry (auto-detected if omitted)
@@ -80,6 +82,8 @@ def _get_command_module(command):
80
82
  "status": "status",
81
83
  "scaffold": "init_cmd",
82
84
  "check": "check",
85
+ "config": "config",
86
+ "undo": "undo",
83
87
  }
84
88
  module_name = module_map.get(command)
85
89
  if not module_name:
@@ -153,10 +157,22 @@ def main():
153
157
  print("Error: no package.json or pyproject.toml found.", file=sys.stderr)
154
158
  sys.exit(1)
155
159
  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)
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
165
+ regs = detect_registries()
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)
160
176
  else:
161
177
  # release, status: use explicit registry or auto-detect primary
162
178
  if not registry:
@@ -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'}")
@@ -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