rlsbl 0.0.1 → 0.1.1
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/LICENSE +21 -0
- package/README.md +155 -0
- package/bin/cli.js +16 -0
- package/package.json +22 -3
- package/rlsbl/__init__.py +174 -1
- package/rlsbl/__main__.py +4 -0
- package/rlsbl/commands/__init__.py +0 -0
- package/rlsbl/commands/check_name.py +184 -0
- package/rlsbl/commands/init_cmd.py +321 -0
- package/rlsbl/commands/release.py +179 -0
- package/rlsbl/commands/status.py +76 -0
- package/rlsbl/registries/__init__.py +5 -0
- package/rlsbl/registries/npm.py +120 -0
- package/rlsbl/registries/pypi.py +172 -0
- package/rlsbl/utils.py +124 -0
- package/templates/npm/ci.yml.tpl +22 -0
- package/templates/npm/publish.yml.tpl +22 -0
- package/templates/pypi/ci.yml.tpl +20 -0
- package/templates/pypi/publish.yml.tpl +18 -0
- package/templates/shared/CHANGELOG.md.tpl +5 -0
- package/templates/shared/CLAUDE.md.tpl +20 -0
- package/templates/shared/LICENSE.tpl +21 -0
- package/templates/shared/check-prs.sh.tpl +10 -0
- package/templates/shared/claude-settings.json.tpl +15 -0
- package/templates/shared/gitignore.tpl +13 -0
- package/templates/shared/pre-push-hook.sh.tpl +42 -0
- package/templates/shared/pre-release.sh.tpl +13 -0
- package/templates/shared/record-gif.sh.tpl +34 -0
- package/pyproject.toml +0 -11
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""npm registry adapter for rlsbl."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
NAME = "npm"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def read_version(dir_path):
|
|
11
|
+
"""Read the version from package.json in the given directory."""
|
|
12
|
+
pkg_path = os.path.join(dir_path, "package.json")
|
|
13
|
+
with open(pkg_path, "r", encoding="utf-8") as f:
|
|
14
|
+
pkg = json.load(f)
|
|
15
|
+
if "version" not in pkg:
|
|
16
|
+
raise ValueError(f"No 'version' field in {pkg_path}")
|
|
17
|
+
return pkg["version"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def write_version(dir_path, version):
|
|
21
|
+
"""Write a new version to package.json, preserving formatting."""
|
|
22
|
+
pkg_path = os.path.join(dir_path, "package.json")
|
|
23
|
+
with open(pkg_path, "r", encoding="utf-8") as f:
|
|
24
|
+
raw = f.read()
|
|
25
|
+
|
|
26
|
+
# Detect indent: look for the first indented line
|
|
27
|
+
indent_match = re.search(r'^( +|\t+)"', raw, re.MULTILINE)
|
|
28
|
+
indent = indent_match.group(1) if indent_match else " "
|
|
29
|
+
|
|
30
|
+
pkg = json.loads(raw)
|
|
31
|
+
pkg["version"] = version
|
|
32
|
+
|
|
33
|
+
# Preserve trailing newline if present
|
|
34
|
+
trailing_newline = "\n" if raw.endswith("\n") else ""
|
|
35
|
+
output = json.dumps(pkg, indent=indent) + trailing_newline
|
|
36
|
+
# Atomic write: write to temp file, then rename
|
|
37
|
+
tmp_path = pkg_path + ".tmp"
|
|
38
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
39
|
+
f.write(output)
|
|
40
|
+
os.replace(tmp_path, pkg_path)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_version_file():
|
|
44
|
+
"""Returns the filename that holds the version for this registry."""
|
|
45
|
+
return "package.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_template_dir():
|
|
49
|
+
"""Returns path to the npm-specific template directory."""
|
|
50
|
+
return os.path.join(os.path.dirname(__file__), "..", "..", "templates", "npm")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_shared_template_dir():
|
|
54
|
+
"""Returns path to the shared template directory."""
|
|
55
|
+
return os.path.join(os.path.dirname(__file__), "..", "..", "templates", "shared")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_template_vars(dir_path):
|
|
59
|
+
"""Extract template variables from the target project's package.json."""
|
|
60
|
+
pkg_path = os.path.join(dir_path, "package.json")
|
|
61
|
+
with open(pkg_path, "r", encoding="utf-8") as f:
|
|
62
|
+
pkg = json.load(f)
|
|
63
|
+
|
|
64
|
+
# Derive binCommand from the bin field (first key if object, or package name)
|
|
65
|
+
bin_command = pkg.get("name", "")
|
|
66
|
+
bin_field = pkg.get("bin")
|
|
67
|
+
if isinstance(bin_field, dict) and bin_field:
|
|
68
|
+
bin_command = next(iter(bin_field))
|
|
69
|
+
elif isinstance(bin_field, str):
|
|
70
|
+
bin_command = pkg.get("name", "")
|
|
71
|
+
|
|
72
|
+
# Derive repoName from repository field
|
|
73
|
+
repo_name = ""
|
|
74
|
+
repository = pkg.get("repository")
|
|
75
|
+
if repository:
|
|
76
|
+
url = repository if isinstance(repository, str) else (repository.get("url") or "")
|
|
77
|
+
match = re.search(r"github\.com[/:]([^/]+/[^/.]+)", url)
|
|
78
|
+
if match:
|
|
79
|
+
repo_name = match.group(1)
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
"name": pkg.get("name", ""),
|
|
83
|
+
"version": pkg.get("version", "0.1.0"),
|
|
84
|
+
"binCommand": bin_command,
|
|
85
|
+
"author": pkg.get("author", ""),
|
|
86
|
+
"repoName": repo_name,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_template_mappings():
|
|
91
|
+
"""Returns npm-specific template mappings (template file -> target path)."""
|
|
92
|
+
return [
|
|
93
|
+
{"template": "ci.yml.tpl", "target": ".github/workflows/ci.yml"},
|
|
94
|
+
{"template": "publish.yml.tpl", "target": ".github/workflows/publish.yml"},
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_shared_template_mappings():
|
|
99
|
+
"""Returns shared template mappings."""
|
|
100
|
+
return [
|
|
101
|
+
{"template": "CHANGELOG.md.tpl", "target": "CHANGELOG.md"},
|
|
102
|
+
{"template": "gitignore.tpl", "target": ".gitignore"},
|
|
103
|
+
{"template": "LICENSE.tpl", "target": "LICENSE"},
|
|
104
|
+
{"template": "CLAUDE.md.tpl", "target": "CLAUDE.md"},
|
|
105
|
+
{"template": "check-prs.sh.tpl", "target": "scripts/check-prs.sh"},
|
|
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
|
+
{"template": "claude-settings.json.tpl", "target": ".claude/settings.json"},
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def check_project_exists(dir_path):
|
|
114
|
+
"""Returns True if a package.json exists in the given directory."""
|
|
115
|
+
return os.path.exists(os.path.join(dir_path, "package.json"))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_project_init_hint():
|
|
119
|
+
"""Hint for users who haven't initialized their project yet."""
|
|
120
|
+
return 'Run "npm init" first'
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""PyPI registry adapter for rlsbl."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import tomllib
|
|
6
|
+
|
|
7
|
+
from ..utils import run
|
|
8
|
+
|
|
9
|
+
NAME = "pypi"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def read_version(dir_path):
|
|
13
|
+
"""Read the version from pyproject.toml in the given directory."""
|
|
14
|
+
toml_path = os.path.join(dir_path, "pyproject.toml")
|
|
15
|
+
with open(toml_path, "rb") as f:
|
|
16
|
+
data = tomllib.load(f)
|
|
17
|
+
try:
|
|
18
|
+
return data["project"]["version"]
|
|
19
|
+
except KeyError:
|
|
20
|
+
raise ValueError(f"No [project].version in {toml_path}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def write_version(dir_path, version):
|
|
24
|
+
"""Write a new version to pyproject.toml using regex replacement.
|
|
25
|
+
|
|
26
|
+
tomllib is read-only (no stdlib TOML writer), so we use a regex to
|
|
27
|
+
replace the version string within the [project] section only,
|
|
28
|
+
preserving all other formatting.
|
|
29
|
+
"""
|
|
30
|
+
toml_path = os.path.join(dir_path, "pyproject.toml")
|
|
31
|
+
with open(toml_path, "r", encoding="utf-8") as f:
|
|
32
|
+
content = f.read()
|
|
33
|
+
|
|
34
|
+
# Find [project] section boundaries to avoid matching version keys
|
|
35
|
+
# in other sections (e.g. [tool.something])
|
|
36
|
+
project_match = re.search(r"^\[project\]\s*$", content, re.MULTILINE)
|
|
37
|
+
if not project_match:
|
|
38
|
+
raise ValueError("No [project] section found in pyproject.toml")
|
|
39
|
+
|
|
40
|
+
section_start = project_match.end()
|
|
41
|
+
# Find next top-level section header or EOF
|
|
42
|
+
next_section = re.search(r"^\[", content[section_start:], re.MULTILINE)
|
|
43
|
+
section_end = section_start + next_section.start() if next_section else len(content)
|
|
44
|
+
|
|
45
|
+
# Replace version only within [project] section
|
|
46
|
+
section = content[section_start:section_end]
|
|
47
|
+
updated_section = re.sub(
|
|
48
|
+
r'^(version\s*=\s*)"[^"]+"',
|
|
49
|
+
rf'\g<1>"{version}"',
|
|
50
|
+
section,
|
|
51
|
+
count=1,
|
|
52
|
+
flags=re.MULTILINE,
|
|
53
|
+
)
|
|
54
|
+
updated = content[:section_start] + updated_section + content[section_end:]
|
|
55
|
+
|
|
56
|
+
# Atomic write: write to temp file, then rename
|
|
57
|
+
tmp_path = toml_path + ".tmp"
|
|
58
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
59
|
+
f.write(updated)
|
|
60
|
+
os.replace(tmp_path, toml_path)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_version_file():
|
|
64
|
+
"""Returns the filename that holds the version for this registry."""
|
|
65
|
+
return "pyproject.toml"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_template_dir():
|
|
69
|
+
"""Returns path to the pypi-specific template directory."""
|
|
70
|
+
return os.path.join(os.path.dirname(__file__), "..", "..", "templates", "pypi")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_shared_template_dir():
|
|
74
|
+
"""Returns path to the shared template directory."""
|
|
75
|
+
return os.path.join(os.path.dirname(__file__), "..", "..", "templates", "shared")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_template_vars(dir_path):
|
|
79
|
+
"""Extract template variables from the target project's pyproject.toml."""
|
|
80
|
+
toml_path = os.path.join(dir_path, "pyproject.toml")
|
|
81
|
+
with open(toml_path, "rb") as f:
|
|
82
|
+
data = tomllib.load(f)
|
|
83
|
+
|
|
84
|
+
project = data.get("project", {})
|
|
85
|
+
name = project.get("name", "")
|
|
86
|
+
version = project.get("version", "0.1.0")
|
|
87
|
+
|
|
88
|
+
# Extract author -- fall back to git config
|
|
89
|
+
author = ""
|
|
90
|
+
try:
|
|
91
|
+
author = run("git", ["config", "user.name"])
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
# Extract repo name from project.urls
|
|
96
|
+
repo_name = ""
|
|
97
|
+
urls = project.get("urls", {})
|
|
98
|
+
for url in urls.values():
|
|
99
|
+
match = re.search(r"github\.com/([^/\s\"]+/[^/\s\"]+)", url)
|
|
100
|
+
if match:
|
|
101
|
+
repo_name = match.group(1).removesuffix(".git")
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
# Derive binCommand from project.scripts (CLI entry points)
|
|
105
|
+
bin_command = ""
|
|
106
|
+
scripts = project.get("scripts", {})
|
|
107
|
+
if scripts:
|
|
108
|
+
bin_command = next(iter(scripts)) # first script entry
|
|
109
|
+
|
|
110
|
+
# Derive the actual Python import name.
|
|
111
|
+
# 1) Check hatch build config for an explicit packages list.
|
|
112
|
+
import_name = None
|
|
113
|
+
hatch = data.get("tool", {}).get("hatch", {})
|
|
114
|
+
packages = (
|
|
115
|
+
hatch.get("build", {}).get("targets", {}).get("wheel", {}).get("packages")
|
|
116
|
+
)
|
|
117
|
+
if packages and isinstance(packages, list) and len(packages) > 0:
|
|
118
|
+
# Strip src/ prefix (common hatch src layout) and convert path to module name
|
|
119
|
+
first_pkg = packages[0]
|
|
120
|
+
import_name = first_pkg.removeprefix("src/").replace("/", ".")
|
|
121
|
+
|
|
122
|
+
# 2) Fall back to filesystem detection, then underscore convention.
|
|
123
|
+
if not import_name:
|
|
124
|
+
underscored = name.replace("-", "_")
|
|
125
|
+
if os.path.isdir(os.path.join(dir_path, underscored)):
|
|
126
|
+
import_name = underscored
|
|
127
|
+
elif os.path.isdir(os.path.join(dir_path, name)):
|
|
128
|
+
import_name = name
|
|
129
|
+
else:
|
|
130
|
+
import_name = underscored # fallback to convention
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
"name": name,
|
|
134
|
+
"version": version,
|
|
135
|
+
"binCommand": bin_command,
|
|
136
|
+
"author": author,
|
|
137
|
+
"repoName": repo_name,
|
|
138
|
+
"importName": import_name,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_template_mappings():
|
|
143
|
+
"""Returns pypi-specific template mappings (template file -> target path)."""
|
|
144
|
+
return [
|
|
145
|
+
{"template": "ci.yml.tpl", "target": ".github/workflows/ci.yml"},
|
|
146
|
+
{"template": "publish.yml.tpl", "target": ".github/workflows/publish.yml"},
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_shared_template_mappings():
|
|
151
|
+
"""Returns shared template mappings."""
|
|
152
|
+
return [
|
|
153
|
+
{"template": "CHANGELOG.md.tpl", "target": "CHANGELOG.md"},
|
|
154
|
+
{"template": "gitignore.tpl", "target": ".gitignore"},
|
|
155
|
+
{"template": "LICENSE.tpl", "target": "LICENSE"},
|
|
156
|
+
{"template": "CLAUDE.md.tpl", "target": "CLAUDE.md"},
|
|
157
|
+
{"template": "check-prs.sh.tpl", "target": "scripts/check-prs.sh"},
|
|
158
|
+
{"template": "record-gif.sh.tpl", "target": "scripts/record-gif.sh"},
|
|
159
|
+
{"template": "pre-release.sh.tpl", "target": "scripts/pre-release.sh"},
|
|
160
|
+
{"template": "pre-push-hook.sh.tpl", "target": "scripts/pre-push-hook.sh"},
|
|
161
|
+
{"template": "claude-settings.json.tpl", "target": ".claude/settings.json"},
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def check_project_exists(dir_path):
|
|
166
|
+
"""Returns True if a pyproject.toml exists in the given directory."""
|
|
167
|
+
return os.path.exists(os.path.join(dir_path, "pyproject.toml"))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_project_init_hint():
|
|
171
|
+
"""Hint for users who haven't initialized their project yet."""
|
|
172
|
+
return 'Run "uv init" first'
|
package/rlsbl/utils.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Git helpers, version bump, changelog extraction, and other shared utilities."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run(cmd, args=None, timeout=30):
|
|
11
|
+
"""Run a command with args, return trimmed stdout. Raise on failure."""
|
|
12
|
+
full_cmd = [cmd] + (args or [])
|
|
13
|
+
result = subprocess.run(full_cmd, capture_output=True, text=True, check=True, timeout=timeout)
|
|
14
|
+
return result.stdout.strip()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_silent(cmd, args=None, timeout=30):
|
|
18
|
+
"""Run a command suppressing stderr. Return trimmed stdout. Raise on failure."""
|
|
19
|
+
full_cmd = [cmd] + (args or [])
|
|
20
|
+
result = subprocess.run(
|
|
21
|
+
full_cmd, capture_output=True, text=True, check=True, timeout=timeout,
|
|
22
|
+
)
|
|
23
|
+
return result.stdout.strip()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_clean_tree():
|
|
27
|
+
"""Returns True if the git working tree is clean (no uncommitted changes)."""
|
|
28
|
+
status = run("git", ["status", "--porcelain"])
|
|
29
|
+
return len(status) == 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_current_branch():
|
|
33
|
+
"""Returns the current git branch name."""
|
|
34
|
+
return run("git", ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def push_if_needed(branch):
|
|
38
|
+
"""Push the branch to origin if local is ahead of remote."""
|
|
39
|
+
local = run("git", ["rev-parse", branch])
|
|
40
|
+
try:
|
|
41
|
+
remote = run("git", ["rev-parse", f"origin/{branch}"])
|
|
42
|
+
except subprocess.CalledProcessError:
|
|
43
|
+
# Remote branch doesn't exist yet; push it
|
|
44
|
+
run("git", ["push", "-u", "origin", branch])
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
if local != remote:
|
|
48
|
+
run("git", ["push", "origin", branch])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def extract_changelog_entry(changelog_path, version):
|
|
52
|
+
"""Extract a changelog entry for a specific version.
|
|
53
|
+
|
|
54
|
+
Looks for a heading like '## 1.2.3' and captures everything
|
|
55
|
+
until the next heading or EOF.
|
|
56
|
+
"""
|
|
57
|
+
with open(changelog_path, "r", encoding="utf-8") as f:
|
|
58
|
+
content = f.read()
|
|
59
|
+
|
|
60
|
+
escaped_version = re.escape(version)
|
|
61
|
+
header_pattern = re.compile(r"^## " + escaped_version + r"\s*$", re.MULTILINE)
|
|
62
|
+
match = header_pattern.search(content)
|
|
63
|
+
if not match:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
# Start after the matched header line
|
|
67
|
+
start_idx = match.end()
|
|
68
|
+
# Find the next "## " heading or use end of string
|
|
69
|
+
next_heading_idx = content.find("\n## ", start_idx)
|
|
70
|
+
end_idx = len(content) if next_heading_idx == -1 else next_heading_idx
|
|
71
|
+
entry = content[start_idx:end_idx].strip()
|
|
72
|
+
return entry or None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def check_gh_installed():
|
|
76
|
+
"""Check that the gh CLI is installed."""
|
|
77
|
+
try:
|
|
78
|
+
run("gh", ["--version"])
|
|
79
|
+
return True
|
|
80
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def check_gh_auth():
|
|
85
|
+
"""Check that the gh CLI is authenticated."""
|
|
86
|
+
try:
|
|
87
|
+
run("gh", ["auth", "status"])
|
|
88
|
+
return True
|
|
89
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def find_commit_tool():
|
|
94
|
+
"""Detect safegit or fall back to git for committing.
|
|
95
|
+
|
|
96
|
+
Returns the path/name of the commit tool.
|
|
97
|
+
"""
|
|
98
|
+
safegit_path = shutil.which("safegit")
|
|
99
|
+
if safegit_path:
|
|
100
|
+
return safegit_path
|
|
101
|
+
return "git"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def bump_version(version, bump_type):
|
|
105
|
+
"""Bump a semver version string by the given type (patch, minor, major).
|
|
106
|
+
|
|
107
|
+
Returns the new version string.
|
|
108
|
+
"""
|
|
109
|
+
parts = version.split(".")
|
|
110
|
+
if len(parts) != 3:
|
|
111
|
+
raise ValueError(f'Invalid semver version: "{version}"')
|
|
112
|
+
try:
|
|
113
|
+
major, minor, patch = (int(p) for p in parts)
|
|
114
|
+
except ValueError:
|
|
115
|
+
raise ValueError(f'Invalid semver version: "{version}"')
|
|
116
|
+
|
|
117
|
+
if bump_type == "major":
|
|
118
|
+
return f"{major + 1}.0.0"
|
|
119
|
+
elif bump_type == "minor":
|
|
120
|
+
return f"{major}.{minor + 1}.0"
|
|
121
|
+
elif bump_type == "patch":
|
|
122
|
+
return f"{major}.{minor}.{patch + 1}"
|
|
123
|
+
else:
|
|
124
|
+
raise ValueError(f'Invalid bump type: "{bump_type}". Use patch, minor, or major.')
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
node-version: [18, 20, 22]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v5
|
|
17
|
+
- uses: actions/setup-node@v5
|
|
18
|
+
with:
|
|
19
|
+
node-version: ${{ matrix.node-version }}
|
|
20
|
+
- run: node -e "require('./package.json')"
|
|
21
|
+
- run: npm test --if-present
|
|
22
|
+
- run: npm audit --audit-level=moderate || true
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
publish:
|
|
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 }}
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
python-version: ["3.12", "3.13", "3.14"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v5
|
|
17
|
+
- uses: astral-sh/setup-uv@v7
|
|
18
|
+
- run: uv python install ${{ matrix.python-version }}
|
|
19
|
+
- run: uv sync
|
|
20
|
+
- run: uv run python -c "import {{importName}}"
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
publish:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v5
|
|
16
|
+
- uses: astral-sh/setup-uv@v7
|
|
17
|
+
- run: uv build
|
|
18
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
## Release workflow
|
|
4
|
+
|
|
5
|
+
This project uses [rlsbl](https://github.com/smm-h/rlsbl) for release orchestration.
|
|
6
|
+
|
|
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
|
|
9
|
+
- CI handles publishing automatically via the publish workflow
|
|
10
|
+
- Never publish manually — always use `rlsbl <registry> release`
|
|
11
|
+
- Requires `NPM_TOKEN` secret on GitHub (for npm projects)
|
|
12
|
+
- Use `rlsbl <registry> release --dry-run` to preview a release without making changes
|
|
13
|
+
|
|
14
|
+
## Conventions
|
|
15
|
+
|
|
16
|
+
- No tokens or secrets in command-line arguments (use env vars or config files)
|
|
17
|
+
- All file writes to shared state should be atomic (write to tmp, then rename)
|
|
18
|
+
- External calls (APIs, CLI tools) must have timeouts and graceful fallbacks
|
|
19
|
+
- Use `npm link` (npm) or `uv pip install -e .` (Python) for local development
|
|
20
|
+
- CI runs smoke tests on every push; manual testing for UI/UX changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) {{year}} {{author}}
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# List open PRs for awareness at session start.
|
|
3
|
+
# Safe to run in hooks -- always exits 0.
|
|
4
|
+
cd "$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
|
|
5
|
+
count=$(gh pr list --state open --json number --jq length 2>/dev/null) || exit 0
|
|
6
|
+
if [ "$count" -gt 0 ]; then
|
|
7
|
+
echo "Open PRs: $count"
|
|
8
|
+
gh pr list --state open
|
|
9
|
+
fi
|
|
10
|
+
exit 0
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Pre-push hook: verify CHANGELOG.md has an entry for the current version.
|
|
3
|
+
# Install: cp scripts/pre-push-hook.sh .git/hooks/pre-push && chmod +x .git/hooks/pre-push
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
# Detect project type and extract version
|
|
8
|
+
if [ -f package.json ]; then
|
|
9
|
+
VERSION=$(node -e "console.log(require('./package.json').version)" 2>/dev/null) || exit 0
|
|
10
|
+
elif [ -f pyproject.toml ]; then
|
|
11
|
+
VERSION=$(grep -m1 '^version' pyproject.toml | sed 's/.*"\(.*\)".*/\1/') || exit 0
|
|
12
|
+
else
|
|
13
|
+
exit 0
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
if [ -z "$VERSION" ]; then
|
|
17
|
+
exit 0
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
# Check CHANGELOG.md has an entry for this version
|
|
21
|
+
if [ ! -f CHANGELOG.md ]; then
|
|
22
|
+
echo "Warning: CHANGELOG.md not found."
|
|
23
|
+
exit 0
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
if ! grep -q "^## $VERSION" CHANGELOG.md; then
|
|
27
|
+
echo "Error: CHANGELOG.md has no entry for version $VERSION."
|
|
28
|
+
echo "Add a '## $VERSION' section before pushing."
|
|
29
|
+
exit 1
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
# Check scaffolding freshness
|
|
33
|
+
if [ -f .rlsbl/version ]; then
|
|
34
|
+
SCAFFOLD_VER=$(cat .rlsbl/version | tr -d '[:space:]')
|
|
35
|
+
if command -v rlsbl &>/dev/null; then
|
|
36
|
+
CURRENT_VER=$(rlsbl --version 2>/dev/null | tr -d '[:space:]')
|
|
37
|
+
if [ -n "$CURRENT_VER" ] && [ "$SCAFFOLD_VER" != "$CURRENT_VER" ]; then
|
|
38
|
+
echo "Warning: scaffolding was generated by rlsbl $SCAFFOLD_VER but you have $CURRENT_VER installed."
|
|
39
|
+
echo "Run 'rlsbl scaffold --update' to update scaffolding."
|
|
40
|
+
fi
|
|
41
|
+
fi
|
|
42
|
+
fi
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Pre-release validation hook.
|
|
3
|
+
# Runs before rlsbl creates a release. Exit non-zero to abort.
|
|
4
|
+
# Add your project-specific checks here (e.g., tests, linting, audit).
|
|
5
|
+
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
echo "Running pre-release checks..."
|
|
9
|
+
|
|
10
|
+
# Example: run tests if available
|
|
11
|
+
# npm test 2>/dev/null || true
|
|
12
|
+
|
|
13
|
+
echo "Pre-release checks passed."
|