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 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 PyPI.
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` or `pyproject.toml`)
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
- After configuration, all subsequent releases are handled by CI when `rlsbl release` creates a GitHub Release.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rlsbl",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Release orchestration and project scaffolding for npm and PyPI",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -61,7 +61,7 @@ NEXT_STEPS = {
61
61
  "Run rlsbl release [patch|minor|major]",
62
62
  ],
63
63
  "go": [
64
- "Install GoReleaser (https://goreleaser.com/install/)",
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
- # Bump type
59
- bump_type = args[0] if args else "patch"
60
- if bump_type not in VALID_BUMP_TYPES:
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
- # Compute new version
68
- new_version = bump_version(current_version, bump_type)
69
- tag = f"v{new_version}"
70
- log(f"New version: {new_version} ({bump_type})")
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
- log(f"Bump: {current_version} -> {new_version} ({bump_type})")
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
- print(f"\nAbout to release {new_version} ({bump_type}) on {branch}")
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 the primary registry file
163
- if version_file:
164
- reg.write_version(".", new_version)
165
- log(f"Updated version in {version_file}")
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
- # Sync version to all other recognized version files
168
- for name, other_reg in REGISTRIES.items():
169
- if name == registry:
170
- continue
171
- if other_reg.check_project_exists("."):
172
- other_file = other_reg.get_version_file()
173
- if other_file:
174
- other_reg.write_version(".", new_version)
175
- log(f"Synced version to {other_file}")
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 all bumped version files together
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 files to commit (version is the git tag)")
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  import sys
4
4
 
5
- from ..utils import run, run_silent, check_gh_installed, check_gh_auth
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}")
@@ -1,8 +1,7 @@
1
1
  """Go registry adapter for rlsbl.
2
2
 
3
- Go modules are versioned by git tags, not version files. GoReleaser handles
4
- the build/publish step. rlsbl's role: changelog validation, tagging, GitHub
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
- Go modules have no version file -- the version IS the git tag.
20
- Returns "0.0.0" if no tags exist yet.
21
- """
22
- try:
23
- tag = run("git", ["describe", "--tags", "--abbrev=0"])
24
- return tag.lstrip("v")
25
- except Exception:
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
- """No-op: Go versions are git tags, not file fields."""
31
- pass
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
- """Go has no version file -- version is the git tag."""
36
- return None
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
- version = read_version(dir_path)
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=30):
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 the path/name of the commit tool.
104
+ Returns "safegit" if available on PATH, otherwise "git".
97
105
  """
98
- safegit_path = shutil.which("safegit")
99
- if safegit_path:
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
- # Add your project-specific checks here (e.g., tests, linting, audit).
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
- # Example: run tests if available
11
- # npm test 2>/dev/null || true
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."