rlsbl 0.8.1 → 0.8.3

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.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Release orchestration and project scaffolding for npm and PyPI",
5
5
  "license": "MIT",
6
6
  "bin": {
package/rlsbl/__init__.py CHANGED
@@ -56,7 +56,8 @@ Usage:
56
56
  rlsbl discover [--mine] List rlsbl ecosystem projects
57
57
  rlsbl watch [<commit-sha>] Watch CI runs for a commit
58
58
  rlsbl pre-push-check Verify CHANGELOG entry for current version
59
- rlsbl record-gif Record a demo GIF with vhs
59
+ rlsbl record-gif [--width N] [--height N] [--font-size N] [--duration N]
60
+ Record a demo GIF with vhs
60
61
 
61
62
  Options:
62
63
  --registry <npm|pypi|go> Target a specific registry (auto-detected if omitted)
@@ -86,7 +87,7 @@ def parse_args(argv):
86
87
  Flags listed in VALUE_FLAGS consume the next token as their value
87
88
  (e.g. --registry npm). All other --flags are boolean.
88
89
  """
89
- VALUE_FLAGS = ("registry",)
90
+ VALUE_FLAGS = ("registry", "width", "height", "font-size", "duration")
90
91
  raw = argv[1:]
91
92
  positional = []
92
93
  flags = {}
@@ -166,6 +167,11 @@ def main():
166
167
  args = positional[1:]
167
168
  registry = flags.get("registry")
168
169
 
170
+ # --registry was the last arg with no value following it
171
+ if registry is True:
172
+ print("Error: --registry requires a value (npm, pypi, or go).", file=sys.stderr)
173
+ sys.exit(1)
174
+
169
175
  # Validate --registry if provided
170
176
  if registry and registry not in REGISTRIES:
171
177
  print(
@@ -1,7 +1,7 @@
1
1
  """Config command: show resolved project configuration."""
2
2
 
3
3
  import os
4
- from ..config import PROJECT_CONFIG, USER_CONFIG, read_json_config, should_tag
4
+ from ..config import _project_config, USER_CONFIG, read_json_config, should_tag
5
5
  from ..registries import REGISTRIES
6
6
 
7
7
 
@@ -50,12 +50,12 @@ def run_cmd(registry, args, flags):
50
50
  print("\nEcosystem tagging:")
51
51
  enabled = should_tag(flags)
52
52
  # Determine why it's enabled/disabled
53
- project_cfg = read_json_config(PROJECT_CONFIG)
53
+ project_cfg = read_json_config(_project_config())
54
54
  user_cfg = read_json_config(USER_CONFIG)
55
55
  if flags.get("no-tag"):
56
56
  source = "CLI flag"
57
57
  elif "tag" in project_cfg:
58
- source = f"project config ({PROJECT_CONFIG})"
58
+ source = f"project config ({_project_config()})"
59
59
  elif "tag" in user_cfg:
60
60
  source = f"user config ({USER_CONFIG})"
61
61
  else:
@@ -1,52 +1,21 @@
1
1
  """Pre-push-check command: verify CHANGELOG.md has an entry for the current version."""
2
2
 
3
- import json
4
3
  import os
5
4
  import re
6
5
  import sys
7
6
 
7
+ from ..registries import REGISTRIES
8
+
8
9
 
9
10
  def _detect_version():
10
- """Detect project type and read the current version.
11
+ """Detect version using registry adapters.
11
12
 
12
- Returns (version_string, project_type) or (None, None) if undetectable.
13
+ Returns (version_string, registry_name) or (None, None) if undetectable.
13
14
  """
14
- if os.path.exists("go.mod"):
15
- # Go projects store version in a VERSION file
16
- version_path = "VERSION"
17
- if os.path.exists(version_path):
18
- with open(version_path, "r", encoding="utf-8") as f:
19
- version = f.read().strip()
20
- if version:
21
- return version, "go"
22
- return None, None
23
-
24
- if os.path.exists("package.json"):
25
- try:
26
- with open("package.json", "r", encoding="utf-8") as f:
27
- data = json.load(f)
28
- version = data.get("version", "")
29
- if version:
30
- return version, "npm"
31
- except Exception:
32
- pass
33
- return None, None
34
-
35
- if os.path.exists("pyproject.toml"):
36
- try:
37
- try:
38
- import tomllib
39
- except ModuleNotFoundError:
40
- import tomli as tomllib # type: ignore[no-redef]
41
- with open("pyproject.toml", "rb") as f:
42
- data = tomllib.load(f)
43
- version = data.get("project", {}).get("version", "")
44
- if version:
45
- return version, "pypi"
46
- except Exception:
47
- pass
48
- return None, None
49
-
15
+ for name in ("go", "npm", "pypi"):
16
+ reg = REGISTRIES[name]
17
+ if reg.check_project_exists("."):
18
+ return reg.read_version("."), name
50
19
  return None, None
51
20
 
52
21
 
@@ -43,19 +43,25 @@ def run_cmd(registry, args, flags):
43
43
  print("Ensure package.json, pyproject.toml, or go.mod exists with a CLI entry point.", file=sys.stderr)
44
44
  sys.exit(1)
45
45
 
46
+ # Parse configurable VHS parameters from flags
47
+ width = int(flags.get("width", 1200))
48
+ height = int(flags.get("height", 600))
49
+ font_size = int(flags.get("font-size", 24))
50
+ duration = int(flags.get("duration", 10))
51
+
46
52
  assets_dir = "assets"
47
53
  os.makedirs(assets_dir, exist_ok=True)
48
54
 
49
55
  # Create a temporary VHS tape file in the project directory
50
56
  tape_content = (
51
57
  'Set FontFamily "monospace"\n'
52
- "Set FontSize 24\n"
53
- "Set Width 1200\n"
54
- "Set Height 600\n"
58
+ f"Set FontSize {font_size}\n"
59
+ f"Set Width {width}\n"
60
+ f"Set Height {height}\n"
55
61
  "Set TypingSpeed 50ms\n"
56
62
  f'Type "{bin_command} --help"\n'
57
63
  "Enter\n"
58
- "Sleep 3s\n"
64
+ f"Sleep {duration}s\n"
59
65
  )
60
66
 
61
67
  tape_fd, tape_path = tempfile.mkstemp(suffix=".tape", dir=".")
@@ -13,9 +13,12 @@ def _notify(title, body):
13
13
  """Send a desktop notification. Non-fatal if unavailable."""
14
14
  try:
15
15
  if sys.platform == "darwin":
16
+ # Escape double quotes to prevent AppleScript injection
17
+ escaped_title = title.replace('"', '\\"')
18
+ escaped_body = body.replace('"', '\\"')
16
19
  subprocess.run(
17
20
  ["osascript", "-e",
18
- f'display notification "{body}" with title "{title}"'],
21
+ f'display notification "{escaped_body}" with title "{escaped_title}"'],
19
22
  timeout=5, capture_output=True,
20
23
  )
21
24
  elif shutil.which("notify-send"):
@@ -33,83 +36,90 @@ def run_cmd(registry, args, flags):
33
36
  Usage: rlsbl watch [<commit-sha>]
34
37
  Defaults to HEAD if no commit SHA is provided.
35
38
  """
36
- # Get commit SHA
37
- if args:
38
- commit_sha = args[0]
39
- else:
39
+ try:
40
+ # Get commit SHA (resolve short SHAs -- gh requires full 40-char)
41
+ if args:
42
+ try:
43
+ commit_sha = run("git", ["rev-parse", args[0]])
44
+ except Exception:
45
+ commit_sha = args[0]
46
+ else:
47
+ try:
48
+ commit_sha = run("git", ["rev-parse", "HEAD"])
49
+ except Exception:
50
+ print("Error: not a git repository and no commit SHA provided.", file=sys.stderr)
51
+ sys.exit(1)
52
+
53
+ # Get repo info for display and URLs
40
54
  try:
41
- commit_sha = run("git", ["rev-parse", "HEAD"])
55
+ repo_info = run("gh", ["repo", "view", "--json", "nameWithOwner,name"])
56
+ info = json.loads(repo_info)
57
+ repo_slug = info.get("nameWithOwner", "")
58
+ repo_name = info.get("name", "")
42
59
  except Exception:
43
- print("Error: not a git repository and no commit SHA provided.", file=sys.stderr)
60
+ print("Error: could not get repo info. Is gh installed and authenticated?", file=sys.stderr)
44
61
  sys.exit(1)
45
62
 
46
- # Get repo info for display and URLs
47
- try:
48
- repo_info = run("gh", ["repo", "view", "--json", "nameWithOwner,name"])
49
- info = json.loads(repo_info)
50
- repo_slug = info.get("nameWithOwner", "")
51
- repo_name = info.get("name", "")
52
- except Exception:
53
- print("Error: could not get repo info. Is gh installed and authenticated?", file=sys.stderr)
54
- sys.exit(1)
55
-
56
- # Try to find a tag for this commit for nicer display
57
- try:
58
- tag = run("git", ["describe", "--tags", "--exact-match", commit_sha])
59
- except Exception:
60
- tag = commit_sha[:12]
61
-
62
- label = f"{repo_name} {tag}" if repo_name else tag
63
-
64
- # Poll until at least one run appears (retry up to 30s)
65
- runs = []
66
- for _ in range(15):
63
+ # Try to find a tag for this commit for nicer display
67
64
  try:
68
- raw = run("gh", ["run", "list", "--commit", commit_sha,
69
- "--json", "databaseId,name,status"])
70
- parsed = json.loads(raw)
71
- if parsed:
72
- runs = parsed
73
- break
65
+ tag = run("git", ["describe", "--tags", "--exact-match", commit_sha])
74
66
  except Exception:
75
- pass
76
- time.sleep(2)
77
-
78
- if not runs:
79
- print(f"rlsbl: {label}: no CI runs found after 30s", file=sys.stderr)
80
- sys.exit(1)
81
-
82
- print(f"rlsbl: {label}: found {len(runs)} CI run(s), watching...", file=sys.stderr)
83
-
84
- # Watch each run sequentially, collecting results
85
- any_failed = False
86
- for ci_run in runs:
87
- run_id = str(ci_run["databaseId"])
88
- workflow_name = ci_run.get("name", f"run {run_id}")
67
+ tag = commit_sha[:12]
68
+
69
+ label = f"{repo_name} {tag}" if repo_name else tag
70
+
71
+ # Poll until at least one run appears (retry up to 30s)
72
+ runs = []
73
+ for _ in range(15):
74
+ try:
75
+ raw = run("gh", ["run", "list", "--commit", commit_sha,
76
+ "--json", "databaseId,name,status"])
77
+ parsed = json.loads(raw)
78
+ if parsed:
79
+ runs = parsed
80
+ break
81
+ except Exception:
82
+ pass
83
+ time.sleep(2)
84
+
85
+ if not runs:
86
+ print(f"rlsbl: {label}: no CI runs found after 30s", file=sys.stderr)
87
+ sys.exit(1)
89
88
 
90
- try:
91
- # gh run watch blocks until the run completes;
92
- # --exit-status makes it exit 1 on failure; check=True raises
93
- # CalledProcessError so we can distinguish pass from fail
94
- subprocess.run(
95
- ["gh", "run", "watch", run_id, "--exit-status"],
96
- capture_output=True, text=True, timeout=3600, check=True,
97
- )
98
- print(f"rlsbl: {label}: {workflow_name} passed", file=sys.stderr)
99
- except subprocess.CalledProcessError:
100
- any_failed = True
101
- print(f"rlsbl: {label}: {workflow_name} FAILED", file=sys.stderr)
102
- if repo_slug:
103
- print(f"rlsbl: https://github.com/{repo_slug}/actions/runs/{run_id}",
104
- file=sys.stderr)
105
- except subprocess.TimeoutExpired:
106
- any_failed = True
107
- print(f"rlsbl: {label}: {workflow_name} timed out after 1h", file=sys.stderr)
108
-
109
- # Desktop notification for overall result
110
- if any_failed:
111
- _notify(f"{label}: CI FAILED", "One or more workflows failed")
112
- else:
113
- _notify(f"{label}: CI passed", "All workflows passed")
114
-
115
- sys.exit(1 if any_failed else 0)
89
+ print(f"rlsbl: {label}: found {len(runs)} CI run(s), watching...", file=sys.stderr)
90
+
91
+ # Watch each run sequentially, collecting results
92
+ any_failed = False
93
+ for ci_run in runs:
94
+ run_id = str(ci_run["databaseId"])
95
+ workflow_name = ci_run.get("name", f"run {run_id}")
96
+
97
+ try:
98
+ # gh run watch blocks until the run completes;
99
+ # --exit-status makes it exit 1 on failure; check=True raises
100
+ # CalledProcessError so we can distinguish pass from fail
101
+ subprocess.run(
102
+ ["gh", "run", "watch", run_id, "--exit-status"],
103
+ capture_output=True, text=True, timeout=3600, check=True,
104
+ )
105
+ print(f"rlsbl: {label}: {workflow_name} passed", file=sys.stderr)
106
+ except subprocess.CalledProcessError:
107
+ any_failed = True
108
+ print(f"rlsbl: {label}: {workflow_name} FAILED", file=sys.stderr)
109
+ if repo_slug:
110
+ print(f"rlsbl: https://github.com/{repo_slug}/actions/runs/{run_id}",
111
+ file=sys.stderr)
112
+ except subprocess.TimeoutExpired:
113
+ any_failed = True
114
+ print(f"rlsbl: {label}: {workflow_name} timed out after 1h", file=sys.stderr)
115
+
116
+ # Desktop notification for overall result
117
+ if any_failed:
118
+ _notify(f"{label}: CI FAILED", "One or more workflows failed")
119
+ else:
120
+ _notify(f"{label}: CI passed", "All workflows passed")
121
+
122
+ sys.exit(1 if any_failed else 0)
123
+ except KeyboardInterrupt:
124
+ print("\nWatch cancelled.", file=sys.stderr)
125
+ sys.exit(130)
package/rlsbl/config.py CHANGED
@@ -11,7 +11,10 @@ import json
11
11
  import os
12
12
 
13
13
 
14
- PROJECT_CONFIG = os.path.join(".rlsbl", "config.json")
14
+ def _project_config():
15
+ """Resolve project config path at call time (respects cwd changes)."""
16
+ return os.path.join(".rlsbl", "config.json")
17
+
15
18
  USER_CONFIG = os.path.expanduser("~/.rlsbl/config.json")
16
19
 
17
20
 
@@ -31,7 +34,7 @@ def should_tag(flags):
31
34
  return False
32
35
 
33
36
  # Project-level config
34
- project = read_json_config(PROJECT_CONFIG)
37
+ project = read_json_config(_project_config())
35
38
  if "tag" in project:
36
39
  return bool(project["tag"])
37
40
 
@@ -46,9 +49,9 @@ def should_tag(flags):
46
49
 
47
50
  def write_project_config(key, value):
48
51
  """Write or update a key in .rlsbl/config.json (creates dir if needed)."""
49
- os.makedirs(os.path.dirname(PROJECT_CONFIG), exist_ok=True)
50
- existing = read_json_config(PROJECT_CONFIG)
52
+ os.makedirs(os.path.dirname(_project_config()), exist_ok=True)
53
+ existing = read_json_config(_project_config())
51
54
  existing[key] = value
52
- with open(PROJECT_CONFIG, "w", encoding="utf-8") as f:
55
+ with open(_project_config(), "w", encoding="utf-8") as f:
53
56
  json.dump(existing, f, indent=2)
54
57
  f.write("\n")
@@ -9,13 +9,10 @@ on:
9
9
  jobs:
10
10
  test:
11
11
  runs-on: ubuntu-latest
12
- strategy:
13
- matrix:
14
- go-version: ["1.22", "1.23", "1.24"]
15
12
  steps:
16
13
  - uses: actions/checkout@v6
17
14
  - uses: actions/setup-go@v6
18
15
  with:
19
- go-version: ${{ matrix.go-version }}
20
- - run: go test ./...
16
+ go-version-file: go.mod
21
17
  - run: go vet ./...
18
+ - run: go test ./... -race -short -timeout=10m
@@ -15,7 +15,7 @@ jobs:
15
15
  - uses: actions/checkout@v6
16
16
  - uses: actions/setup-node@v6
17
17
  with:
18
- node-version: 22
18
+ node-version: 24
19
19
  registry-url: https://registry.npmjs.org
20
20
  - run: npm publish --provenance --access public
21
21
  env:
@@ -11,7 +11,7 @@ jobs:
11
11
  runs-on: ubuntu-latest
12
12
  strategy:
13
13
  matrix:
14
- node-version: [18, 20, 22]
14
+ node-version: [20, 22, 24]
15
15
  steps:
16
16
  - uses: actions/checkout@v6
17
17
  - uses: actions/setup-node@v6
@@ -15,7 +15,7 @@ jobs:
15
15
  - uses: actions/checkout@v6
16
16
  - uses: actions/setup-node@v6
17
17
  with:
18
- node-version: 22
18
+ node-version: 24
19
19
  registry-url: https://registry.npmjs.org
20
20
  - run: npm publish --provenance --access public
21
21
  env:
@@ -14,11 +14,6 @@ if [ -f go.mod ]; then
14
14
  go test ./... -race -short -count=1
15
15
  fi
16
16
 
17
- if [ -f package.json ] && node -e "process.exit(require('./package.json').scripts?.test ? 0 : 1)" 2>/dev/null; then
18
- echo " npm: test"
19
- npm test
20
- fi
21
-
22
17
  if [ -f pyproject.toml ]; then
23
18
  echo " Python: pytest"
24
19
  if command -v uv &>/dev/null; then
@@ -28,4 +23,9 @@ if [ -f pyproject.toml ]; then
28
23
  fi
29
24
  fi
30
25
 
26
+ if [ -f package.json ] && node -e "process.exit(require('./package.json').scripts?.test ? 0 : 1)" 2>/dev/null; then
27
+ echo " npm: test"
28
+ npm test
29
+ fi
30
+
31
31
  echo "Pre-release checks passed."