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 +8 -8
- package/package.json +1 -1
- package/rlsbl/__init__.py +88 -75
- package/rlsbl/commands/{check_name.py → check.py} +3 -3
- package/rlsbl/commands/config.py +48 -0
- package/rlsbl/commands/init_cmd.py +144 -58
- package/rlsbl/commands/release.py +24 -5
- package/rlsbl/commands/undo.py +66 -0
- package/templates/merged/publish.yml.tpl +30 -0
- package/templates/shared/CLAUDE.md.tpl +3 -3
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
|
|
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
|
|
37
|
-
rlsbl
|
|
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
|
|
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
|
|
68
|
+
rlsbl status --registry pypi
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
### check
|
|
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
|
|
77
|
-
rlsbl
|
|
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
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
|
|
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
|
|
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
|
|
23
|
+
rlsbl check <name> Check name availability
|
|
24
|
+
rlsbl config Show project configuration
|
|
25
|
+
rlsbl undo [--yes] Revert the last release
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
56
|
+
i = 0
|
|
57
|
+
while i < len(raw):
|
|
58
|
+
arg = raw[i]
|
|
51
59
|
if arg.startswith("--"):
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
109
|
+
command = positional[0] if positional else None
|
|
92
110
|
|
|
93
111
|
# Resolve command aliases (e.g. "init" -> "scaffold")
|
|
94
|
-
if
|
|
95
|
-
|
|
112
|
+
if command in COMMAND_ALIASES:
|
|
113
|
+
command = COMMAND_ALIASES[command]
|
|
96
114
|
|
|
97
|
-
if not
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
print(
|
|
133
|
-
|
|
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
|
|
143
|
-
#
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
handler.run_cmd(
|
|
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
|
-
#
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
10
|
+
- Never publish manually — always use `rlsbl release`
|
|
11
11
|
- Requires `NPM_TOKEN` secret on GitHub (for npm projects)
|
|
12
|
-
- Use `rlsbl
|
|
12
|
+
- Use `rlsbl release --dry-run` to preview a release without making changes
|
|
13
13
|
|
|
14
14
|
## Conventions
|
|
15
15
|
|