strictcli 0.31.0__tar.gz → 0.32.0__tar.gz

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.
Files changed (88) hide show
  1. strictcli-0.32.0/CLAUDE.md +26 -0
  2. {strictcli-0.31.0 → strictcli-0.32.0}/PKG-INFO +1 -1
  3. {strictcli-0.31.0 → strictcli-0.32.0}/pyproject.toml +1 -1
  4. {strictcli-0.31.0 → strictcli-0.32.0}/strictcli/__init__.py +67 -22
  5. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_coverage.py +102 -6
  6. {strictcli-0.31.0 → strictcli-0.32.0}/uv.lock +1 -1
  7. strictcli-0.31.0/CLAUDE.md +0 -20
  8. {strictcli-0.31.0 → strictcli-0.32.0}/.claude/settings.json +0 -0
  9. {strictcli-0.31.0 → strictcli-0.32.0}/.github/workflows/ci.yml +0 -0
  10. {strictcli-0.31.0 → strictcli-0.32.0}/.github/workflows/publish.yml +0 -0
  11. {strictcli-0.31.0 → strictcli-0.32.0}/.gitignore +0 -0
  12. {strictcli-0.31.0 → strictcli-0.32.0}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
  13. {strictcli-0.31.0 → strictcli-0.32.0}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
  14. {strictcli-0.31.0 → strictcli-0.32.0}/.rlsbl/bases/.gitignore +0 -0
  15. {strictcli-0.31.0 → strictcli-0.32.0}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
  16. {strictcli-0.31.0 → strictcli-0.32.0}/.rlsbl/config.json +0 -0
  17. {strictcli-0.31.0 → strictcli-0.32.0}/.rlsbl/lint/python.toml +0 -0
  18. {strictcli-0.31.0 → strictcli-0.32.0}/.rlsbl/managed-files.json +0 -0
  19. {strictcli-0.31.0 → strictcli-0.32.0}/.rlsbl/version +0 -0
  20. {strictcli-0.31.0 → strictcli-0.32.0}/.strictcli/schema.json +0 -0
  21. {strictcli-0.31.0 → strictcli-0.32.0}/LICENSE +0 -0
  22. {strictcli-0.31.0 → strictcli-0.32.0}/README.md +0 -0
  23. {strictcli-0.31.0 → strictcli-0.32.0}/strictcli/py.typed +0 -0
  24. {strictcli-0.31.0 → strictcli-0.32.0}/tests/conftest.py +0 -0
  25. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_arg_default.py +0 -0
  26. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_arg_default_validation.py +0 -0
  27. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_at_prefix.py +0 -0
  28. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_auto_version.py +0 -0
  29. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_call.py +0 -0
  30. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_check_command.py +0 -0
  31. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_check_discovery.py +0 -0
  32. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_check_provider.py +0 -0
  33. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_check_public_api.py +0 -0
  34. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_check_runner.py +0 -0
  35. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_check_schema.py +0 -0
  36. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_check_types.py +0 -0
  37. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_choices.py +0 -0
  38. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_choices_none.py +0 -0
  39. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_command_help_suggestion.py +0 -0
  40. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_command_tags.py +0 -0
  41. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_compound_types.py +0 -0
  42. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_config.py +0 -0
  43. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_config_fields.py +0 -0
  44. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_config_file_path.py +0 -0
  45. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_config_set_bugs.py +0 -0
  46. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_context.py +0 -0
  47. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_deep_nesting.py +0 -0
  48. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_dependencies.py +0 -0
  49. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_deprecated.py +0 -0
  50. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_dump_schema.py +0 -0
  51. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_e2e.py +0 -0
  52. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_env.py +0 -0
  53. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_exit_codes.py +0 -0
  54. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_flag_sets.py +0 -0
  55. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_float_format.py +0 -0
  56. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_float_type.py +0 -0
  57. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_float_vectors.py +0 -0
  58. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_global_flag_conflict_position.py +0 -0
  59. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_global_flags.py +0 -0
  60. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_help.py +0 -0
  61. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_hermetic.py +0 -0
  62. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_infra_env.py +0 -0
  63. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_int_type.py +0 -0
  64. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_invoke.py +0 -0
  65. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_keyword_flags.py +0 -0
  66. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_mcp.py +0 -0
  67. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_mutex.py +0 -0
  68. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_nesting.py +0 -0
  69. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_parser.py +0 -0
  70. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_passthrough.py +0 -0
  71. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_provenance.py +0 -0
  72. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_provenance_phase2.py +0 -0
  73. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_registration.py +0 -0
  74. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_repeatable.py +0 -0
  75. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_reserved_global_flags.py +0 -0
  76. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_tagdsl.py +0 -0
  77. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_toml_loading.py +0 -0
  78. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_tool_export.py +0 -0
  79. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_typed_args.py +0 -0
  80. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_unique.py +0 -0
  81. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_utilities.py +0 -0
  82. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_validate.py +0 -0
  83. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_variadic.py +0 -0
  84. {strictcli-0.31.0 → strictcli-0.32.0}/tests/test_visibility.py +0 -0
  85. {strictcli-0.31.0 → strictcli-0.32.0}/todo/.defer/deferred.md +0 -0
  86. {strictcli-0.31.0 → strictcli-0.32.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  87. {strictcli-0.31.0 → strictcli-0.32.0}/todo/.done/original-idea.md +0 -0
  88. {strictcli-0.31.0 → strictcli-0.32.0}/todo/.done/public-check-runner-api.md +0 -0
@@ -0,0 +1,26 @@
1
+ # strictcli (Python)
2
+
3
+ Python implementation of strictcli. Published to PyPI. Part of the strictcli
4
+ rlsbl monorepo as releasable `py-strictcli`.
5
+
6
+ ## Development
7
+
8
+ - `uv sync` then `uv run pytest` from this directory. Always use `uv`, never pip.
9
+ - Run the conformance suite after changes: `cd ../conformance && python run.py --target python`.
10
+ - CI (`ci-router.yml` at the repo root) runs the full pytest suite on every push touching `python/**`.
11
+
12
+ ## Release workflow
13
+
14
+ Releases go through [rlsbl](https://github.com/smm-h/rlsbl) monorepo releases.
15
+
16
+ - CHANGELOG.md is generated from JSONL changelog entries — never edit it by hand.
17
+ Add entries with `rlsbl changelog add` from inside `python/` after each commit
18
+ (they land in `.rlsbl-monorepo/releasables/py-strictcli/changes/unreleased.jsonl`).
19
+ - To release: from the repo root, run `rlsbl monorepo release init`, edit the
20
+ scaffolded release file (bump type, description, and context live in the file),
21
+ then run `rlsbl monorepo release run --no-allow-dirty --watch --yes`.
22
+ - Never publish or push manually — CI publishes to PyPI via Trusted Publishing.
23
+
24
+ ## Conventions
25
+
26
+ - No tokens or secrets in command-line arguments (use env vars or config files).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strictcli
3
- Version: 0.31.0
3
+ Version: 0.32.0
4
4
  Summary: A strict CLI framework for Python
5
5
  Project-URL: Homepage, https://github.com/smm-h/strictcli
6
6
  Project-URL: Repository, https://github.com/smm-h/strictcli
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "strictcli"
7
- version = "0.31.0"
7
+ version = "0.32.0"
8
8
  description = "A strict CLI framework for Python"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.31.0"
5
+ __version__ = "0.32.0"
6
6
 
7
7
  __all__ = [
8
8
  "App", "Flag", "Arg", "FlagSet", "MutexGroup", "CoRequired", "Requires",
@@ -2803,13 +2803,25 @@ class App:
2803
2803
  # that every command in the app's surface has been exercised.
2804
2804
  self._coverage_shard_counter: int = 0
2805
2805
  self._coverage_shard_path: str | None = None
2806
+ self._coverage_dir: str | None = None
2807
+ self._coverage_manifest_path: str | None = None
2806
2808
  self._last_resolved_path: list[str] = []
2807
2809
  if self.test_coverage:
2810
+ # Anchor the coverage root to the cwd AT CONSTRUCTION TIME. Both the
2811
+ # recorder and the check provider use these absolute paths so that
2812
+ # tests which chdir still record into the repo, and a check evaluated
2813
+ # from a foreign cwd reads the app's own repo state.
2814
+ self._coverage_dir = os.path.abspath(
2815
+ os.path.join(".strictcli", "coverage")
2816
+ )
2817
+ self._coverage_manifest_path = os.path.abspath(
2818
+ os.path.join(".strictcli", "test-coverage.json")
2819
+ )
2808
2820
  self._coverage_shard_path = os.path.join(
2809
- ".strictcli", "coverage",
2821
+ self._coverage_dir,
2810
2822
  f"{os.getpid()}-{{n}}.jsonl",
2811
2823
  )
2812
- os.makedirs(os.path.join(".strictcli", "coverage"), exist_ok=True)
2824
+ os.makedirs(self._coverage_dir, exist_ok=True)
2813
2825
  self.register_check_provider(self._test_coverage_provider)
2814
2826
 
2815
2827
  def _validate_flag_infra_marker(self, f: Flag) -> None:
@@ -2868,18 +2880,46 @@ class App:
2868
2880
  def _test_coverage_provider(self) -> list[CheckSpec]:
2869
2881
  """Built-in check provider for cli-test-coverage.
2870
2882
 
2871
- Registered automatically when test_coverage=True. Merges per-process
2872
- shard files into the canonical manifest, compares against the schema's
2873
- command surface (minus deprecated), and hard-FAILs listing every command
2874
- with zero coverage.
2883
+ Registered automatically when test_coverage=True. The verdict is derived
2884
+ from committed state: the covered set is the union of the committed
2885
+ manifest (.strictcli/test-coverage.json) and any per-process shard files
2886
+ merged from .strictcli/coverage/. Every live registered command path
2887
+ (minus the injected check command) must be present in that union to pass;
2888
+ otherwise the check fails naming each uncovered command.
2889
+
2890
+ Because the verdict reads the committed manifest, it is deterministic on
2891
+ every machine -- a machine that never ran the suite (no local shards)
2892
+ still gets a stable verdict from the committed manifest alone. Both the
2893
+ coverage dir and the manifest path are anchored to the App's
2894
+ construction-time cwd, so the check evaluated from a foreign cwd reads
2895
+ the app's own repo state.
2896
+
2897
+ The manifest is rewritten as the monotonic union of its prior contents
2898
+ and the freshly merged shards, but ONLY when that content actually
2899
+ changes -- a pure check must not dirty a byte-identical file. Accepted
2900
+ staleness: deleting a test leaves its command covered in the manifest
2901
+ until the manifest is deliberately regenerated (e.g. by removing it and
2902
+ re-running the suite), because the union never removes a command.
2875
2903
  """
2876
2904
  def impl(ctx: CheckContext, reporter: "ErrorReporter") -> "_CheckOutcome":
2877
- coverage_dir = os.path.join(".strictcli", "coverage")
2878
- manifest_path = os.path.join(".strictcli", "test-coverage.json")
2905
+ coverage_dir = self._coverage_dir
2906
+ manifest_path = self._coverage_manifest_path
2879
2907
 
2880
- # Merge shards
2881
2908
  covered: set[str] = set()
2882
- if os.path.isdir(coverage_dir):
2909
+
2910
+ # Seed from the committed manifest -- this is what makes the verdict
2911
+ # deterministic on machines that never ran the suite.
2912
+ if manifest_path and os.path.isfile(manifest_path):
2913
+ try:
2914
+ with open(manifest_path, encoding="utf-8") as f:
2915
+ data = json.load(f)
2916
+ if isinstance(data, list):
2917
+ covered.update(c for c in data if isinstance(c, str))
2918
+ except (json.JSONDecodeError, OSError):
2919
+ pass
2920
+
2921
+ # Merge shards (optional freshness input)
2922
+ if coverage_dir and os.path.isdir(coverage_dir):
2883
2923
  for fname in os.listdir(coverage_dir):
2884
2924
  if not fname.endswith(".jsonl"):
2885
2925
  continue
@@ -2893,17 +2933,22 @@ class App:
2893
2933
  if "command" in entry:
2894
2934
  covered.add(entry["command"])
2895
2935
 
2896
- if not covered:
2897
- reporter.error("stale or empty manifest")
2898
- return reporter.found(
2899
- "no coverage data: .strictcli/coverage/ contains no shard files"
2900
- )
2901
-
2902
- # Write canonical manifest
2903
- manifest = sorted(covered)
2904
- with open(manifest_path, "w", encoding="utf-8") as f:
2905
- json.dump(manifest, f, indent=2)
2906
- f.write("\n")
2936
+ # Rewrite the manifest as the monotonic union, but only when the
2937
+ # content actually changes (keeps a pure check from dirtying a
2938
+ # byte-identical file).
2939
+ if manifest_path and covered:
2940
+ new_content = json.dumps(sorted(covered), indent=2) + "\n"
2941
+ existing: str | None = None
2942
+ if os.path.isfile(manifest_path):
2943
+ try:
2944
+ with open(manifest_path, encoding="utf-8") as f:
2945
+ existing = f.read()
2946
+ except OSError:
2947
+ existing = None
2948
+ if existing != new_content:
2949
+ os.makedirs(os.path.dirname(manifest_path), exist_ok=True)
2950
+ with open(manifest_path, "w", encoding="utf-8") as f:
2951
+ f.write(new_content)
2907
2952
 
2908
2953
  # Compare against command surface (exclude the framework-injected
2909
2954
  # check command -- it is not a user command)
@@ -175,10 +175,15 @@ class TestCoverageCheck:
175
175
  cov_result = next(r for r in results if r.name == "cli-test-coverage")
176
176
  assert cov_result.status == "pass"
177
177
 
178
- def test_empty_coverage_is_hard_error(self, tmp_path):
179
- """No shard files -> check FAILs with stale/empty manifest error."""
178
+ def test_empty_coverage_fails_naming_all(self, tmp_path):
179
+ """No shards and no manifest -> deterministic FAIL naming every command.
180
+
181
+ The verdict is derived from committed state (manifest union shards), so an
182
+ empty everything state fails by listing every uncovered command rather than
183
+ the old machine-dependent "no coverage data" error.
184
+ """
180
185
  app = _make_app(tmp_path)
181
- # Don't run any test() or call() -- no shards exist
186
+ # Don't run any test() or call() -- no shards, no manifest
182
187
 
183
188
  results, _, code = app.run_checks(
184
189
  SimpleCtx(project_root=tmp_path),
@@ -186,9 +191,13 @@ class TestCoverageCheck:
186
191
  )
187
192
  cov_result = next(r for r in results if r.name == "cli-test-coverage")
188
193
  assert cov_result.status == "fail"
189
- assert "stale or empty" in cov_result.message or any(
190
- "stale or empty" in p.text for p in cov_result.problems
191
- )
194
+ problem_texts = [p.text for p in cov_result.problems]
195
+ uncovered = {
196
+ t.split("no test coverage for command: ")[1]
197
+ for t in problem_texts
198
+ if "no test coverage for command:" in t
199
+ }
200
+ assert uncovered == {"build", "deploy", "status"}
192
201
 
193
202
  def test_manifest_written_on_check(self, tmp_path):
194
203
  """The check writes .strictcli/test-coverage.json with covered commands."""
@@ -239,6 +248,93 @@ class TestCoverageCheck:
239
248
  assert any("infra.teardown" in t for t in problem_texts)
240
249
 
241
250
 
251
+ class TestCoverageChdirSafety:
252
+ def test_record_anchored_to_construction_cwd(self, tmp_path):
253
+ """test() records into the construction-time coverage dir, not the cwd
254
+ that a test happened to chdir into."""
255
+ app = _make_app(tmp_path) # constructs with cwd == tmp_path
256
+ other = tmp_path / "elsewhere"
257
+ other.mkdir()
258
+ os.chdir(other)
259
+
260
+ app.test(["deploy"])
261
+
262
+ construction_shards = list(
263
+ (tmp_path / ".strictcli" / "coverage").glob("*.jsonl")
264
+ )
265
+ assert construction_shards, "shard must land under the construction cwd"
266
+ foreign = other / ".strictcli" / "coverage"
267
+ assert not foreign.exists(), "must not record into the chdir'd cwd"
268
+
269
+
270
+ class TestManifestUnionVerdict:
271
+ def test_manifest_present_shards_absent_passes(self, tmp_path):
272
+ """Committed manifest covering every command -> deterministic PASS even
273
+ with no shard files (the machine never ran the suite)."""
274
+ app = _make_app(tmp_path)
275
+ manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
276
+ manifest_path.write_text(
277
+ json.dumps(["build", "deploy", "status"], indent=2) + "\n"
278
+ )
279
+
280
+ results, _, code = app.run_checks(
281
+ SimpleCtx(project_root=tmp_path),
282
+ run_all=True,
283
+ )
284
+ cov_result = next(r for r in results if r.name == "cli-test-coverage")
285
+ assert cov_result.status == "pass"
286
+
287
+ def test_check_from_foreign_cwd_reads_app_state(self, tmp_path):
288
+ """The check evaluated from a foreign cwd reads the app's own repo state
289
+ (anchored manifest), not the foreign directory."""
290
+ app = _make_app(tmp_path)
291
+ manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
292
+ manifest_path.write_text(
293
+ json.dumps(["build", "deploy", "status"], indent=2) + "\n"
294
+ )
295
+ other = tmp_path / "foreign"
296
+ other.mkdir()
297
+ os.chdir(other)
298
+
299
+ results, _, code = app.run_checks(
300
+ SimpleCtx(project_root=tmp_path),
301
+ run_all=True,
302
+ )
303
+ cov_result = next(r for r in results if r.name == "cli-test-coverage")
304
+ assert cov_result.status == "pass"
305
+
306
+ def test_manifest_union_is_monotonic(self, tmp_path):
307
+ """A run recording only a subset keeps prior commands covered (union)."""
308
+ app = _make_app(tmp_path)
309
+ manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
310
+ manifest_path.write_text(
311
+ json.dumps(["build", "deploy", "status"], indent=2) + "\n"
312
+ )
313
+ app.test(["deploy"]) # only one recorded this run
314
+
315
+ app.run_checks(SimpleCtx(project_root=tmp_path), run_all=True)
316
+
317
+ manifest = json.loads(manifest_path.read_text())
318
+ assert sorted(manifest) == ["build", "deploy", "status"]
319
+
320
+ def test_manifest_not_rewritten_when_unchanged(self, tmp_path):
321
+ """A pure check must not dirty a byte-identical manifest."""
322
+ app = _make_app(tmp_path)
323
+ app.test(["deploy"])
324
+ app.test(["status"])
325
+ app.test(["build"])
326
+ app.run_checks(SimpleCtx(project_root=tmp_path), run_all=True)
327
+
328
+ manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
329
+ content1 = manifest_path.read_text()
330
+ mtime1 = manifest_path.stat().st_mtime_ns
331
+
332
+ app.run_checks(SimpleCtx(project_root=tmp_path), run_all=True)
333
+
334
+ assert manifest_path.read_text() == content1
335
+ assert manifest_path.stat().st_mtime_ns == mtime1
336
+
337
+
242
338
  class TestCoverageDisabled:
243
339
  def test_no_recording_when_disabled(self, tmp_path):
244
340
  """test_coverage=False (default) produces no shard files."""
@@ -232,7 +232,7 @@ wheels = [
232
232
 
233
233
  [[package]]
234
234
  name = "strictcli"
235
- version = "0.31.0"
235
+ version = "0.32.0"
236
236
  source = { editable = "." }
237
237
  dependencies = [
238
238
  { name = "tomlkit" },
@@ -1,20 +0,0 @@
1
- # strictcli
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 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 release`
11
- - Configure Trusted Publishing on pypi.org for automated PyPI releases
12
- - Use `rlsbl 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 `uv pip install -e .` for local development
20
- - CI runs smoke tests on every push; manual testing for UI/UX changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes