rlsbl 0.4.0 → 0.4.2
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 +17 -5
- package/package.json +1 -1
- package/rlsbl/commands/init_cmd.py +1 -1
- package/rlsbl/commands/release.py +51 -31
- package/rlsbl/commands/undo.py +2 -2
- package/rlsbl/registries/go.py +23 -18
- package/rlsbl/utils.py +22 -15
- package/templates/go/VERSION.tpl +1 -0
- package/templates/shared/pre-push-hook.sh.tpl +2 -0
- package/templates/shared/pre-release.sh.tpl +17 -3
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="logo.svg" alt="rlsbl" width="336" height="105">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
1
5
|
# rlsbl
|
|
2
6
|
|
|
3
|
-
Release orchestration and project scaffolding CLI for npm and
|
|
7
|
+
Release orchestration and project scaffolding CLI for npm, PyPI, and Go.
|
|
4
8
|
|
|
5
9
|
## Install
|
|
6
10
|
|
|
@@ -25,7 +29,7 @@ rlsbl release minor
|
|
|
25
29
|
|
|
26
30
|
## Commands
|
|
27
31
|
|
|
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.
|
|
32
|
+
All commands work at the top level -- registries are auto-detected from project files (`package.json`, `pyproject.toml`, `go.mod`). Use `--registry <npm|pypi|go>` when you need to target a specific registry.
|
|
29
33
|
|
|
30
34
|
### scaffold [--force] [--update]
|
|
31
35
|
|
|
@@ -35,6 +39,7 @@ Scaffolds CI/CD infrastructure and release tooling for all detected registries.
|
|
|
35
39
|
rlsbl scaffold
|
|
36
40
|
rlsbl scaffold --registry npm # target npm only
|
|
37
41
|
rlsbl scaffold --registry pypi --force # overwrite existing files
|
|
42
|
+
rlsbl scaffold --registry go # target Go only
|
|
38
43
|
```
|
|
39
44
|
|
|
40
45
|
Context-aware behavior when files already exist (without `--force`):
|
|
@@ -55,7 +60,7 @@ rlsbl release minor
|
|
|
55
60
|
rlsbl release major --dry-run --registry npm
|
|
56
61
|
```
|
|
57
62
|
|
|
58
|
-
The version is synced across all detected project files (`package.json`, `pyproject.toml`) regardless of which registry is primary.
|
|
63
|
+
The version is synced across all detected project files (`package.json`, `pyproject.toml`, `VERSION`) regardless of which registry is primary. Go projects use a plain `VERSION` file as the version source.
|
|
59
64
|
|
|
60
65
|
If `scripts/pre-release.sh` exists, it runs before any changes are made. A non-zero exit aborts the release.
|
|
61
66
|
|
|
@@ -122,7 +127,7 @@ The scaffolded `scripts/pre-push-hook.sh` is installed as a git pre-push hook du
|
|
|
122
127
|
|
|
123
128
|
How it works:
|
|
124
129
|
|
|
125
|
-
1. Detects project type (`package.json
|
|
130
|
+
1. Detects project type (`package.json`, `pyproject.toml`, or `VERSION`)
|
|
126
131
|
2. Extracts the current version
|
|
127
132
|
3. Checks that `CHANGELOG.md` contains a heading `## <version>`
|
|
128
133
|
4. Blocks the push with an error if the entry is missing
|
|
@@ -141,8 +146,15 @@ The first version must be published manually before CI can take over:
|
|
|
141
146
|
|---|---|---|
|
|
142
147
|
| npm | Add an `NPM_TOKEN` secret to your GitHub repo (Settings > Secrets > Actions), then push a release | CI handles subsequent publishes |
|
|
143
148
|
| PyPI | Run `uv publish` | Set up [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) on pypi.org |
|
|
149
|
+
| Go | Push to GitHub and create a release -- Go modules are published by the tag itself | No secrets needed; `pkg.go.dev` indexes automatically |
|
|
150
|
+
|
|
151
|
+
After configuration, all subsequent releases are handled by CI when `rlsbl release` creates a GitHub Release. Go projects use GoReleaser in CI (via GitHub Actions) to build cross-platform binaries.
|
|
152
|
+
|
|
153
|
+
## Environment variables
|
|
144
154
|
|
|
145
|
-
|
|
155
|
+
| Variable | Default | Description |
|
|
156
|
+
|----------|---------|-------------|
|
|
157
|
+
| `RLSBL_PUSH_TIMEOUT` | `120` | Timeout in seconds for `git push` operations. Increase if your pre-push hooks (e.g. test suites) take longer than 2 minutes. |
|
|
146
158
|
|
|
147
159
|
## Requirements
|
|
148
160
|
|
package/package.json
CHANGED
|
@@ -61,7 +61,7 @@ NEXT_STEPS = {
|
|
|
61
61
|
"Run rlsbl release [patch|minor|major]",
|
|
62
62
|
],
|
|
63
63
|
"go": [
|
|
64
|
-
"
|
|
64
|
+
"GoReleaser runs in CI via GitHub Actions (no local install needed)",
|
|
65
65
|
"Push to GitHub to activate the CI workflow",
|
|
66
66
|
"Run rlsbl release [patch|minor|major]",
|
|
67
67
|
],
|
|
@@ -12,6 +12,7 @@ from ..utils import (
|
|
|
12
12
|
extract_changelog_entry,
|
|
13
13
|
find_commit_tool,
|
|
14
14
|
get_current_branch,
|
|
15
|
+
get_push_timeout,
|
|
15
16
|
is_clean_tree,
|
|
16
17
|
push_if_needed,
|
|
17
18
|
run,
|
|
@@ -55,19 +56,30 @@ def run_cmd(registry, args, flags):
|
|
|
55
56
|
current_version = reg.read_version(".")
|
|
56
57
|
log(f"Current version: {current_version}")
|
|
57
58
|
|
|
58
|
-
#
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
print(
|
|
62
|
-
f'Error: invalid bump type "{bump_type}". Use: {", ".join(VALID_BUMP_TYPES)}',
|
|
63
|
-
file=sys.stderr,
|
|
64
|
-
)
|
|
65
|
-
sys.exit(1)
|
|
59
|
+
# If the current version has never been tagged, release it as-is (bootstrap)
|
|
60
|
+
current_tag = f"v{current_version}"
|
|
61
|
+
current_tag_exists = len(run("git", ["tag", "-l", current_tag])) > 0
|
|
66
62
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
63
|
+
if not current_tag_exists:
|
|
64
|
+
new_version = current_version
|
|
65
|
+
bump_type = None
|
|
66
|
+
tag = current_tag
|
|
67
|
+
if args:
|
|
68
|
+
log(f"First release: releasing {new_version} as-is (bump type ignored)")
|
|
69
|
+
else:
|
|
70
|
+
log(f"First release: {new_version}")
|
|
71
|
+
else:
|
|
72
|
+
bump_type = args[0] if args else "patch"
|
|
73
|
+
if bump_type not in VALID_BUMP_TYPES:
|
|
74
|
+
print(
|
|
75
|
+
f'Error: invalid bump type "{bump_type}". Use: {", ".join(VALID_BUMP_TYPES)}',
|
|
76
|
+
file=sys.stderr,
|
|
77
|
+
)
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
|
|
80
|
+
new_version = bump_version(current_version, bump_type)
|
|
81
|
+
tag = f"v{new_version}"
|
|
82
|
+
log(f"New version: {new_version} ({bump_type})")
|
|
71
83
|
|
|
72
84
|
# Check tag doesn't already exist
|
|
73
85
|
tag_output = run("git", ["tag", "-l", tag])
|
|
@@ -111,7 +123,10 @@ def run_cmd(registry, args, flags):
|
|
|
111
123
|
if flags.get("dry-run", False):
|
|
112
124
|
log("\n--- Dry run summary ---")
|
|
113
125
|
log(f"Registry: {registry}")
|
|
114
|
-
|
|
126
|
+
if bump_type:
|
|
127
|
+
log(f"Bump: {current_version} -> {new_version} ({bump_type})")
|
|
128
|
+
else:
|
|
129
|
+
log(f"Version: {new_version} (first release)")
|
|
115
130
|
log(f"Tag: {tag}")
|
|
116
131
|
log(f"Branch: {branch}")
|
|
117
132
|
# Show other version files that would be synced
|
|
@@ -144,7 +159,8 @@ def run_cmd(registry, args, flags):
|
|
|
144
159
|
|
|
145
160
|
# Confirmation prompt (skip with --yes)
|
|
146
161
|
if not flags.get("yes"):
|
|
147
|
-
|
|
162
|
+
bump_label = f" ({bump_type})" if bump_type else ""
|
|
163
|
+
print(f"\nAbout to release {new_version}{bump_label} on {branch}")
|
|
148
164
|
print(f" Tag: {tag}")
|
|
149
165
|
if files_to_commit:
|
|
150
166
|
print(f" Files: {', '.join(files_to_commit)}")
|
|
@@ -159,23 +175,24 @@ def run_cmd(registry, args, flags):
|
|
|
159
175
|
print("Aborted.")
|
|
160
176
|
sys.exit(0)
|
|
161
177
|
|
|
162
|
-
# Write new version to
|
|
163
|
-
if
|
|
164
|
-
|
|
165
|
-
|
|
178
|
+
# Write new version to version files (skip if version didn't change, e.g. first release)
|
|
179
|
+
if new_version != current_version:
|
|
180
|
+
if version_file:
|
|
181
|
+
reg.write_version(".", new_version)
|
|
182
|
+
log(f"Updated version in {version_file}")
|
|
166
183
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
184
|
+
# Sync version to all other recognized version files
|
|
185
|
+
for name, other_reg in REGISTRIES.items():
|
|
186
|
+
if name == registry:
|
|
187
|
+
continue
|
|
188
|
+
if other_reg.check_project_exists("."):
|
|
189
|
+
other_file = other_reg.get_version_file()
|
|
190
|
+
if other_file:
|
|
191
|
+
other_reg.write_version(".", new_version)
|
|
192
|
+
log(f"Synced version to {other_file}")
|
|
176
193
|
|
|
177
|
-
# Commit
|
|
178
|
-
if files_to_commit:
|
|
194
|
+
# Commit version file changes (skip if version didn't change, e.g. first release)
|
|
195
|
+
if files_to_commit and new_version != current_version:
|
|
179
196
|
commit_tool = find_commit_tool()
|
|
180
197
|
if commit_tool == "safegit":
|
|
181
198
|
run(commit_tool, ["commit", "-m", tag, "--", *files_to_commit])
|
|
@@ -184,15 +201,18 @@ def run_cmd(registry, args, flags):
|
|
|
184
201
|
run("git", ["commit", "-m", tag])
|
|
185
202
|
log(f"Committed: {tag}")
|
|
186
203
|
else:
|
|
187
|
-
log("No version
|
|
204
|
+
log("No version bump to commit")
|
|
188
205
|
|
|
189
206
|
# Create local git tag
|
|
190
207
|
run("git", ["tag", tag])
|
|
191
208
|
log(f"Tagged: {tag}")
|
|
192
209
|
|
|
193
210
|
# Push commits and tag
|
|
211
|
+
push_timeout = get_push_timeout()
|
|
212
|
+
if push_timeout != 120:
|
|
213
|
+
log(f"Push timeout: {push_timeout}s (from RLSBL_PUSH_TIMEOUT)")
|
|
194
214
|
push_if_needed(branch)
|
|
195
|
-
run("git", ["push", "origin", tag])
|
|
215
|
+
run("git", ["push", "origin", tag], timeout=push_timeout)
|
|
196
216
|
log(f"Pushed to origin/{branch}")
|
|
197
217
|
|
|
198
218
|
# Create GitHub Release using a temp notes file
|
package/rlsbl/commands/undo.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import sys
|
|
4
4
|
|
|
5
|
-
from ..utils import run,
|
|
5
|
+
from ..utils import run, check_gh_installed, check_gh_auth, get_push_timeout
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def run_cmd(registry, args, flags):
|
|
@@ -40,7 +40,7 @@ def run_cmd(registry, args, flags):
|
|
|
40
40
|
|
|
41
41
|
# Delete remote tag
|
|
42
42
|
try:
|
|
43
|
-
run("git", ["push", "origin", f":{tag}"])
|
|
43
|
+
run("git", ["push", "origin", f":{tag}"], timeout=get_push_timeout())
|
|
44
44
|
print(f"Deleted remote tag: {tag}")
|
|
45
45
|
except Exception as e:
|
|
46
46
|
print(f"Warning: could not delete remote tag: {e}")
|
package/rlsbl/registries/go.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
"""Go registry adapter for rlsbl.
|
|
2
2
|
|
|
3
|
-
Go
|
|
4
|
-
the build/publish step
|
|
5
|
-
Release creation.
|
|
3
|
+
Go projects use a VERSION file as the source of truth for rlsbl. GoReleaser
|
|
4
|
+
handles the build/publish step triggered by the GitHub Release that rlsbl creates.
|
|
6
5
|
"""
|
|
7
6
|
|
|
8
7
|
import os
|
|
@@ -12,28 +11,30 @@ from ..utils import run
|
|
|
12
11
|
|
|
13
12
|
NAME = "go"
|
|
14
13
|
|
|
14
|
+
VERSION_FILE = "VERSION"
|
|
15
15
|
|
|
16
|
-
def read_version(dir_path):
|
|
17
|
-
"""Read version from the latest git tag.
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return "0.0.0"
|
|
17
|
+
def read_version(dir_path):
|
|
18
|
+
"""Read version from the VERSION file."""
|
|
19
|
+
version_path = os.path.join(dir_path, VERSION_FILE)
|
|
20
|
+
if not os.path.exists(version_path):
|
|
21
|
+
raise FileNotFoundError(f"No {VERSION_FILE} file found. Run 'rlsbl scaffold' first.")
|
|
22
|
+
with open(version_path, "r", encoding="utf-8") as f:
|
|
23
|
+
return f.read().strip()
|
|
27
24
|
|
|
28
25
|
|
|
29
26
|
def write_version(dir_path, version):
|
|
30
|
-
"""
|
|
31
|
-
|
|
27
|
+
"""Write the new version to the VERSION file."""
|
|
28
|
+
version_path = os.path.join(dir_path, VERSION_FILE)
|
|
29
|
+
tmp_path = version_path + ".tmp"
|
|
30
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
31
|
+
f.write(version + "\n")
|
|
32
|
+
os.replace(tmp_path, version_path)
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
def get_version_file():
|
|
35
|
-
"""
|
|
36
|
-
return
|
|
36
|
+
"""Returns the filename that holds the version for this registry."""
|
|
37
|
+
return VERSION_FILE
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
def get_template_dir():
|
|
@@ -73,7 +74,10 @@ def get_template_vars(dir_path):
|
|
|
73
74
|
except Exception:
|
|
74
75
|
pass
|
|
75
76
|
|
|
76
|
-
|
|
77
|
+
try:
|
|
78
|
+
version = read_version(dir_path)
|
|
79
|
+
except FileNotFoundError:
|
|
80
|
+
version = "0.0.0"
|
|
77
81
|
|
|
78
82
|
return {
|
|
79
83
|
"name": short_name,
|
|
@@ -88,6 +92,7 @@ def get_template_vars(dir_path):
|
|
|
88
92
|
def get_template_mappings():
|
|
89
93
|
"""Returns go-specific template mappings (template file -> target path)."""
|
|
90
94
|
return [
|
|
95
|
+
{"template": "VERSION.tpl", "target": "VERSION"},
|
|
91
96
|
{"template": "ci.yml.tpl", "target": ".github/workflows/ci.yml"},
|
|
92
97
|
{"template": "publish.yml.tpl", "target": ".github/workflows/publish.yml"},
|
|
93
98
|
{"template": "goreleaser.yml.tpl", "target": ".goreleaser.yml"},
|
package/rlsbl/utils.py
CHANGED
|
@@ -7,21 +7,13 @@ import subprocess
|
|
|
7
7
|
import sys
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
def run(cmd, args=None, timeout=
|
|
10
|
+
def run(cmd, args=None, timeout=120):
|
|
11
11
|
"""Run a command with args, return trimmed stdout. Raise on failure."""
|
|
12
12
|
full_cmd = [cmd] + (args or [])
|
|
13
13
|
result = subprocess.run(full_cmd, capture_output=True, text=True, check=True, timeout=timeout)
|
|
14
14
|
return result.stdout.strip()
|
|
15
15
|
|
|
16
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
17
|
|
|
26
18
|
def is_clean_tree():
|
|
27
19
|
"""Returns True if the git working tree is clean (no uncommitted changes)."""
|
|
@@ -34,18 +26,34 @@ def get_current_branch():
|
|
|
34
26
|
return run("git", ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
35
27
|
|
|
36
28
|
|
|
29
|
+
def get_push_timeout():
|
|
30
|
+
"""Return the push timeout in seconds, from RLSBL_PUSH_TIMEOUT or default 120."""
|
|
31
|
+
raw = os.environ.get("RLSBL_PUSH_TIMEOUT")
|
|
32
|
+
if raw is None:
|
|
33
|
+
return 120
|
|
34
|
+
try:
|
|
35
|
+
val = int(raw)
|
|
36
|
+
if val <= 0:
|
|
37
|
+
raise ValueError
|
|
38
|
+
return val
|
|
39
|
+
except ValueError:
|
|
40
|
+
print(f'Warning: invalid RLSBL_PUSH_TIMEOUT="{raw}", using default 120s', file=sys.stderr)
|
|
41
|
+
return 120
|
|
42
|
+
|
|
43
|
+
|
|
37
44
|
def push_if_needed(branch):
|
|
38
45
|
"""Push the branch to origin if local is ahead of remote."""
|
|
46
|
+
timeout = get_push_timeout()
|
|
39
47
|
local = run("git", ["rev-parse", branch])
|
|
40
48
|
try:
|
|
41
49
|
remote = run("git", ["rev-parse", f"origin/{branch}"])
|
|
42
50
|
except subprocess.CalledProcessError:
|
|
43
51
|
# Remote branch doesn't exist yet; push it
|
|
44
|
-
run("git", ["push", "-u", "origin", branch])
|
|
52
|
+
run("git", ["push", "-u", "origin", branch], timeout=timeout)
|
|
45
53
|
return
|
|
46
54
|
|
|
47
55
|
if local != remote:
|
|
48
|
-
run("git", ["push", "origin", branch])
|
|
56
|
+
run("git", ["push", "origin", branch], timeout=timeout)
|
|
49
57
|
|
|
50
58
|
|
|
51
59
|
def extract_changelog_entry(changelog_path, version):
|
|
@@ -93,11 +101,10 @@ def check_gh_auth():
|
|
|
93
101
|
def find_commit_tool():
|
|
94
102
|
"""Detect safegit or fall back to git for committing.
|
|
95
103
|
|
|
96
|
-
Returns
|
|
104
|
+
Returns "safegit" if available on PATH, otherwise "git".
|
|
97
105
|
"""
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return safegit_path
|
|
106
|
+
if shutil.which("safegit"):
|
|
107
|
+
return "safegit"
|
|
101
108
|
return "git"
|
|
102
109
|
|
|
103
110
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{{version}}
|
|
@@ -9,6 +9,8 @@ if [ -f package.json ]; then
|
|
|
9
9
|
VERSION=$(node -e "console.log(require('./package.json').version)" 2>/dev/null) || exit 0
|
|
10
10
|
elif [ -f pyproject.toml ]; then
|
|
11
11
|
VERSION=$(grep -m1 '^version' pyproject.toml | sed 's/.*"\(.*\)".*/\1/') || exit 0
|
|
12
|
+
elif [ -f VERSION ]; then
|
|
13
|
+
VERSION=$(tr -d '[:space:]' < VERSION) || exit 0
|
|
12
14
|
else
|
|
13
15
|
exit 0
|
|
14
16
|
fi
|
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Pre-release validation hook.
|
|
3
3
|
# Runs before rlsbl creates a release. Exit non-zero to abort.
|
|
4
|
-
#
|
|
4
|
+
# Detects project type and runs appropriate checks automatically.
|
|
5
5
|
|
|
6
6
|
set -euo pipefail
|
|
7
7
|
|
|
8
8
|
echo "Running pre-release checks..."
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
if [ -f go.mod ]; then
|
|
11
|
+
echo "Detected Go project"
|
|
12
|
+
go vet ./...
|
|
13
|
+
go build ./...
|
|
14
|
+
go test ./... -race -short -count=1
|
|
15
|
+
elif [ -f package.json ]; then
|
|
16
|
+
echo "Detected npm project"
|
|
17
|
+
npm test
|
|
18
|
+
elif [ -f pyproject.toml ]; then
|
|
19
|
+
echo "Detected Python project"
|
|
20
|
+
if command -v uv &>/dev/null; then
|
|
21
|
+
uv run pytest
|
|
22
|
+
elif command -v pytest &>/dev/null; then
|
|
23
|
+
pytest
|
|
24
|
+
fi
|
|
25
|
+
fi
|
|
12
26
|
|
|
13
27
|
echo "Pre-release checks passed."
|