cctally 1.8.2 → 1.10.0

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/bin/cctally CHANGED
@@ -43,19 +43,6 @@ if sys.version_info < __min_python_version__:
43
43
  if __name__ == "__main__":
44
44
  sys.modules.setdefault("cctally", sys.modules["__main__"])
45
45
 
46
- # Bind `cctally` as a local alias for sibling-back-reference patterns within
47
- # this module. STAYING release helpers (e.g. `_release_run_phase_stamp`) call
48
- # MOVED helpers (e.g. `_release_stamp_changelog` in `_cctally_release.py`)
49
- # via `cctally.<name>(...)`. Attribute access on the module object triggers
50
- # PEP 562 `__getattr__`, which lazy-loads `_cctally_release` and splices the
51
- # whole symbol set into this module's globals on first hit. Subsequent
52
- # accesses bypass `__getattr__` (normal dict lookup wins). Spec §5.5.
53
- #
54
- # Falls back to the current module if `cctally` isn't yet registered (e.g.
55
- # under unusual loaders); the binding is then a self-reference, which is
56
- # harmless because the same lookup-then-PEP562 chain applies.
57
- cctally = sys.modules.get("cctally") or sys.modules[__name__]
58
-
59
46
  import argparse
60
47
  import bisect
61
48
  import contextlib
@@ -170,7 +157,6 @@ SETUP_SYMLINK_NAMES = (
170
157
  "cctally-forecast",
171
158
  "cctally-project",
172
159
  "cctally-refresh-usage",
173
- "cctally-release",
174
160
  "cctally-sync-week",
175
161
  "cctally-tui",
176
162
  "cctally-update",
@@ -202,12 +188,10 @@ PUBLIC_REPO = "omrikais/cctally"
202
188
 
203
189
  # === Eager-loaded library siblings ===
204
190
  #
205
- # Re-export pure-fn primitives from _lib_*.py so test/harness code that
206
- # reaches into cctally._release_parse_semver, cctally._SEMVER_RE, etc.
207
- # continues to work via the same attribute path. The actual definitions
208
- # live in the sibling modules; bin/cctally's namespace simply re-aliases
209
- # them. Eager because these are touched by code defined later in this
210
- # file (e.g. RELEASE_HEADER_RE uses _SEMVER_NUM).
191
+ # Re-export pure-fn primitives from _lib_*.py so code defined later in
192
+ # this file (e.g. RELEASE_HEADER_RE uses _SEMVER_NUM) can reach them via
193
+ # the cctally module namespace. The actual definitions live in the
194
+ # sibling modules; bin/cctally's namespace simply re-aliases them.
211
195
  #
212
196
  # Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §6.3-6.4
213
197
 
@@ -220,8 +204,6 @@ _cctally_core = _load_sibling("_cctally_core")
220
204
  # Eager re-exports for the 24 kernel symbols. Preserves
221
205
  # `cctally.<name>` attribute access AND `ns["<name>"]` dict-subscript
222
206
  # paths used heavily by tests (e.g. ns["open_db"]() in 125+ sites).
223
- # Don't add to PEP 562 __getattr__ registry — eager binding is the
224
- # whole point.
225
207
  eprint = _cctally_core.eprint
226
208
  now_utc_iso = _cctally_core.now_utc_iso
227
209
  parse_iso_datetime = _cctally_core.parse_iso_datetime
@@ -251,10 +233,6 @@ get_latest_usage_for_week = _cctally_core.get_latest_usage_for_week
251
233
  _lib_semver = _load_sibling("_lib_semver")
252
234
  _SEMVER_NUM = _lib_semver._SEMVER_NUM
253
235
  _SEMVER_RE = _lib_semver._SEMVER_RE
254
- _release_parse_semver = _lib_semver._release_parse_semver
255
- _release_format_semver = _lib_semver._release_format_semver
256
- _release_compute_next_version = _lib_semver._release_compute_next_version
257
- _release_semver_sort_key = _lib_semver._release_semver_sort_key
258
236
 
259
237
  _lib_pricing = _load_sibling("_lib_pricing")
260
238
  TIERED_THRESHOLD = _lib_pricing.TIERED_THRESHOLD
@@ -329,6 +307,17 @@ _aggregate_block = _lib_blocks._aggregate_block
329
307
  _build_activity_block = _lib_blocks._build_activity_block
330
308
  _blocks_to_json = _lib_blocks._blocks_to_json
331
309
 
310
+ _lib_changelog = _load_sibling("_lib_changelog")
311
+ # Backward-compat re-export: callers and tests reach the helper through
312
+ # ``cctally._release_read_latest_release_version`` (incl. monkeypatch
313
+ # sites in tests/test_release_internals.py + tests/test_update.py and
314
+ # the ``_cctally()._release_read_latest_release_version()`` call in
315
+ # bin/_cctally_release.py). The canonical name is
316
+ # ``_read_latest_changelog_version`` on _lib_changelog; the alias keeps
317
+ # the historical cctally-namespace name reachable for the monkeypatch
318
+ # surface those tests depend on.
319
+ _release_read_latest_release_version = _lib_changelog._read_latest_changelog_version
320
+
332
321
  _lib_aggregators = _load_sibling("_lib_aggregators")
333
322
  BucketUsage = _lib_aggregators.BucketUsage
334
323
  CodexBucketUsage = _lib_aggregators.CodexBucketUsage
@@ -400,13 +389,11 @@ _render_five_hour_blocks_table = _lib_render._render_five_hour_blocks_table
400
389
 
401
390
  # Eager re-export of bin/_cctally_setup.py (lazy I/O sibling that loads
402
391
  # at startup to keep `ns["cmd_setup"](...)` / `ns["_setup_X"](...)`
403
- # direct-dict test patterns working — PEP 562 `__getattr__` only fires
404
- # on module-attribute access, not dict-key access on `mod.__dict__`,
405
- # which is how `tests/test_setup_legacy_migrate.py` reaches in via the
406
- # `ns = load_script()` fixture. Spec §4.8 explicitly contemplates this
407
- # "partially-pre-imported lazy module" form for the rare case where
408
- # tests bypass PEP 562. Same pattern as Phase A `_lib_*` modules; no
409
- # entry in `_LAZY_MODULES` below.
392
+ # direct-dict test patterns working — dict-key access on `mod.__dict__`
393
+ # (the form tests/test_setup_legacy_migrate.py uses via the
394
+ # `ns = load_script()` fixture) bypasses any module-level `__getattr__`.
395
+ # Eager binding here makes the symbols visible on the dict regardless
396
+ # of how callers reach them. Same pattern as the `_lib_*` modules above.
410
397
  _cctally_setup = _load_sibling("_cctally_setup")
411
398
  _settings_merge_install = _cctally_setup._settings_merge_install
412
399
  _settings_merge_uninstall = _cctally_setup._settings_merge_uninstall
@@ -779,6 +766,7 @@ _build_monthly_share_panel_data = _cctally_dashboard._build_monthly_share_panel_
779
766
  _build_forecast_share_panel_data = _cctally_dashboard._build_forecast_share_panel_data
780
767
  _build_blocks_share_panel_data = _cctally_dashboard._build_blocks_share_panel_data
781
768
  _build_sessions_share_panel_data = _cctally_dashboard._build_sessions_share_panel_data
769
+ _build_projects_share_panel_data = _cctally_dashboard._build_projects_share_panel_data
782
770
  _SnapshotRef = _cctally_dashboard._SnapshotRef
783
771
  SSEHub = _cctally_dashboard.SSEHub
784
772
  STATIC_DIR = _cctally_dashboard.STATIC_DIR
@@ -794,6 +782,13 @@ _dashboard_build_blocks_view = _cctally_dashboard._dashboard_build_blocks_view
794
782
  _dashboard_build_daily_panel = _cctally_dashboard._dashboard_build_daily_panel
795
783
  _empty_dashboard_snapshot = _cctally_dashboard._empty_dashboard_snapshot
796
784
  _iso_z = _cctally_dashboard._iso_z
785
+ # Projects panel + modal (spec 2026-05-19-projects-panel-design.md).
786
+ # Re-export so the sync-thread builder at `_cctally_tui._tui_build_snapshot`
787
+ # can reach the dashboard sibling's aggregator via `c = _cctally()`.
788
+ _build_projects_envelope = _cctally_dashboard._build_projects_envelope
789
+ _projects_reset_memo = _cctally_dashboard._projects_reset_memo
790
+ _project_detail_for_window = _cctally_dashboard._project_detail_for_window
791
+ _handle_get_project_detail_impl = _cctally_dashboard._handle_get_project_detail_impl
797
792
  _select_current_block_for_envelope = _cctally_dashboard._select_current_block_for_envelope
798
793
  _build_alerts_envelope_array = _cctally_dashboard._build_alerts_envelope_array
799
794
  snapshot_to_envelope = _cctally_dashboard.snapshot_to_envelope
@@ -803,73 +798,6 @@ DashboardHTTPHandler = _cctally_dashboard.DashboardHTTPHandler
803
798
  cmd_dashboard = _cctally_dashboard.cmd_dashboard
804
799
 
805
800
 
806
- # === Lazy-loaded library siblings (PEP 562 registry) ===
807
- #
808
- # Tests reach into cctally's namespace for private symbols via
809
- # SourceFileLoader (e.g. cctally._release_phase_stamp_done). When those
810
- # symbols live in lazy I/O modules (_cctally_*.py), Python's
811
- # module-level __getattr__ resolves them on first access by importing
812
- # the owning module and splicing its whole symbol surface into
813
- # bin/cctally's globals. Subsequent accesses skip __getattr__ entirely.
814
- #
815
- # Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §4
816
-
817
- _LAZY_MODULES: dict[str, list[str]] = {
818
- "_cctally_release": [
819
- "_FORMULA_VERSION_RE",
820
- "_RELEASE_NPM_POLL_INTERVAL_S_DEFAULT",
821
- "_RELEASE_NPM_POLL_TIMEOUT_S_DEFAULT",
822
- "_homebrew_template_path",
823
- "_package_json_path",
824
- "_release_brew_archive_url",
825
- "_release_canonical_body",
826
- "_release_compute_brew_sha256",
827
- "_release_dry_run",
828
- "_release_extract_formula_version",
829
- "_release_npm_poll_timing",
830
- "_release_parse_changelog",
831
- "_release_preflight_tag_clobber",
832
- "_release_print_gh_fallback",
833
- "_release_stamp_changelog",
834
- "_release_stamp_package_json",
835
- "cmd_release",
836
- ],
837
- }
838
-
839
- # Inverted lookup: symbol -> module name. Built at module load with a
840
- # collision assertion so two modules can't accidentally claim the same name.
841
- _LAZY_NAME_TO_MODULE: dict[str, str] = {}
842
- for _mod_name, _names in _LAZY_MODULES.items():
843
- for _n in _names:
844
- assert _n not in _LAZY_NAME_TO_MODULE, (
845
- f"PEP 562 registry collision: {_n!r} claimed by both "
846
- f"{_LAZY_NAME_TO_MODULE.get(_n)!r} and {_mod_name!r}"
847
- )
848
- _LAZY_NAME_TO_MODULE[_n] = _mod_name
849
-
850
-
851
- def __getattr__(name: str): # PEP 562
852
- """Lazy-load a registered sibling module on first attribute access.
853
-
854
- Tests do `cctally._release_phase_stamp_done(...)` and similar; this
855
- function fires, imports the owning module via _load_sibling, splices
856
- every symbol the registry claims for that module into globals(), and
857
- returns the requested attribute. Subsequent attribute accesses on
858
- cctally.X find the symbol directly in globals — no __getattr__ trip.
859
-
860
- Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §4.2
861
- """
862
- mod_name = _LAZY_NAME_TO_MODULE.get(name)
863
- if mod_name is None:
864
- raise AttributeError(
865
- f"module {__name__!r} has no attribute {name!r}"
866
- )
867
- mod = _load_sibling(mod_name)
868
- for n in _LAZY_MODULES[mod_name]:
869
- globals()[n] = getattr(mod, n)
870
- return globals()[name]
871
-
872
-
873
801
  RELEASE_HEADER_RE = re.compile(
874
802
  rf'^## \[({_SEMVER_NUM}\.{_SEMVER_NUM}\.{_SEMVER_NUM}'
875
803
  rf'(?:-[a-zA-Z][a-zA-Z0-9-]*\.{_SEMVER_NUM})?)\] - (\d{{4}}-\d{{2}}-\d{{2}})\s*$',
@@ -879,1284 +807,11 @@ RELEASE_HEADER_RE = re.compile(
879
807
  RELEASE_SUBSECTION_ORDER = ("Added", "Changed", "Deprecated", "Removed", "Fixed", "Security")
880
808
 
881
809
 
882
- def _release_read_latest_release_version() -> tuple[str, str] | None:
883
- """Read latest `## [X.Y.Z] - YYYY-MM-DD` header. Returns (version, date) or None."""
884
- try:
885
- text = CHANGELOG_PATH.read_text(encoding="utf-8")
886
- except FileNotFoundError:
887
- return None
888
- m = RELEASE_HEADER_RE.search(text)
889
- if not m:
890
- return None
891
- return (m.group(1), m.group(2))
892
-
893
-
894
- def _release_preflight_branch(allow_branch: str | None) -> None:
895
- """Refuse unless on main or --allow-branch matches.
896
-
897
- Spec §10.1 step 2: branch check fires for both real runs and --dry-run
898
- (a dry-run on the wrong branch would mislead).
899
- """
900
- branch = subprocess.check_output(
901
- ["git", "rev-parse", "--abbrev-ref", "HEAD"],
902
- text=True,
903
- cwd=str(CHANGELOG_PATH.parent),
904
- ).strip()
905
- expected = allow_branch if allow_branch else "main"
906
- if branch != expected:
907
- if allow_branch:
908
- print(
909
- f"release: refusing to cut from {branch}; allow-branch was {allow_branch}",
910
- file=sys.stderr,
911
- )
912
- else:
913
- print(
914
- f"release: refusing to cut from {branch}; "
915
- f"use --allow-branch {branch} if intentional",
916
- file=sys.stderr,
917
- )
918
- sys.exit(2)
919
-
920
-
921
- def _release_preflight_clean_tree() -> None:
922
- """Refuse if working tree is dirty (spec §10.1 step 3)."""
923
- out = subprocess.check_output(
924
- ["git", "status", "--porcelain"],
925
- text=True,
926
- cwd=str(CHANGELOG_PATH.parent),
927
- )
928
- if out.strip():
929
- print("release: working tree dirty; commit or stash first", file=sys.stderr)
930
- sys.exit(2)
931
-
932
-
933
- def _release_preflight_up_to_date(remote: str) -> None:
934
- """Refuse if local branch is behind <remote>/<branch>; local-ahead OK.
935
-
936
- Spec §10.1 step 4. Network failure is non-fatal — operator can re-run
937
- with --resume after fixing connectivity.
938
- """
939
- branch = subprocess.check_output(
940
- ["git", "rev-parse", "--abbrev-ref", "HEAD"],
941
- text=True,
942
- cwd=str(CHANGELOG_PATH.parent),
943
- ).strip()
944
- try:
945
- subprocess.check_call(
946
- ["git", "fetch", "--quiet", remote, branch],
947
- stdout=subprocess.DEVNULL,
948
- stderr=subprocess.DEVNULL,
949
- cwd=str(CHANGELOG_PATH.parent),
950
- )
951
- except subprocess.CalledProcessError:
952
- # Network failure — proceed; operator can re-run with --resume.
953
- return
954
- local = subprocess.check_output(
955
- ["git", "rev-parse", branch],
956
- text=True,
957
- cwd=str(CHANGELOG_PATH.parent),
958
- ).strip()
959
- remote_sha = subprocess.check_output(
960
- ["git", "rev-parse", f"{remote}/{branch}"],
961
- text=True,
962
- cwd=str(CHANGELOG_PATH.parent),
963
- ).strip()
964
- if local == remote_sha:
965
- return
966
- base = subprocess.check_output(
967
- ["git", "merge-base", local, remote_sha],
968
- text=True,
969
- cwd=str(CHANGELOG_PATH.parent),
970
- ).strip()
971
- if base == remote_sha:
972
- return # local is strictly ahead — fine.
973
- print(
974
- f"release: local {branch} is behind {remote}/{branch}; pull first",
975
- file=sys.stderr,
976
- )
977
- sys.exit(2)
978
-
979
-
980
- def _release_phase_stamp_done(version: str) -> tuple[bool, str | None]:
981
- """Phase-1 read-only signal: returns (True, head_sha) when CHANGELOG.md
982
- on disk AND HEAD's tree both contain a `## [version] - YYYY-MM-DD` header
983
- line AND HEAD's commit subject is exactly `chore(release): vX.Y.Z`.
984
-
985
- Date is read from the existing CHANGELOG, NOT recomputed from "today" —
986
- `--resume` after UTC midnight rollover must still detect a stamp written
987
- yesterday. Returns (False, None) on any miss (file gone, header absent,
988
- HEAD blob lacks the header). When the header IS present on HEAD but
989
- HEAD's subject is not the stamp subject — meaning an unrelated commit
990
- landed on top of the stamp — exits 2 with a diagnostic rather than
991
- tagging the wrong SHA. Read-only — never mutates.
992
- """
993
- expected_prefix = f"## [{version}] - "
994
- try:
995
- text = CHANGELOG_PATH.read_text(encoding="utf-8")
996
- except FileNotFoundError:
997
- return (False, None)
998
- if not any(line.startswith(expected_prefix) for line in text.splitlines()):
999
- return (False, None)
1000
- head_sha = subprocess.check_output(
1001
- ["git", "rev-parse", "HEAD"],
1002
- text=True,
1003
- cwd=str(CHANGELOG_PATH.parent),
1004
- ).strip()
1005
- try:
1006
- blob = subprocess.check_output(
1007
- ["git", "show", "HEAD:CHANGELOG.md"],
1008
- text=True,
1009
- stderr=subprocess.DEVNULL,
1010
- cwd=str(CHANGELOG_PATH.parent),
1011
- )
1012
- except subprocess.CalledProcessError:
1013
- return (False, None)
1014
- if not any(line.startswith(expected_prefix) for line in blob.splitlines()):
1015
- return (False, None)
1016
- # HEAD's blob carries the stamp; verify HEAD itself IS the stamp commit.
1017
- # An unrelated commit landed on top of the stamp would still satisfy the
1018
- # blob check (CHANGELOG.md unchanged on top), but tagging that SHA would
1019
- # mis-tag the release. Refuse with a clear diagnostic instead.
1020
- head_subject = subprocess.check_output(
1021
- ["git", "log", "-1", "--format=%s", "HEAD"],
1022
- text=True,
1023
- cwd=str(CHANGELOG_PATH.parent),
1024
- ).strip()
1025
- expected_subject = f"chore(release): v{version}"
1026
- if head_subject != expected_subject:
1027
- print(
1028
- f"release: HEAD is not the stamp commit (subject: {head_subject!r}); "
1029
- f"--resume cannot continue. Either checkout the stamp commit or "
1030
- f"revert the on-top commits.",
1031
- file=sys.stderr,
1032
- )
1033
- sys.exit(2)
1034
- # Belt-and-suspenders: package.json's version must also be stamped
1035
- # (Phase 1 co-stamp invariant). When package.json doesn't exist
1036
- # (legacy fixtures, source clones pre-Task-1), skip silently —
1037
- # the CHANGELOG header check above is still authoritative.
1038
- pj_path = cctally._package_json_path()
1039
- if pj_path.exists():
1040
- try:
1041
- pj = json.loads(pj_path.read_text(encoding="utf-8"))
1042
- except json.JSONDecodeError:
1043
- return (False, None)
1044
- if pj.get("version") != version:
1045
- return (False, None)
1046
- return (True, head_sha)
1047
-
1048
-
1049
- def _release_build_stamp_message(
1050
- version: str,
1051
- body: str,
1052
- invocation: str,
1053
- prior_head: str,
1054
- bump_kind: str,
1055
- subsection_counts: dict[str, int],
1056
- ) -> str:
1057
- """Build the full stamp commit message: private body + `--- public ---`
1058
- block + canonical body (spec §7.1).
1059
-
1060
- Subject is identical on both surfaces (`chore(release): vX.Y.Z`); the
1061
- public body is the canonical CHANGELOG section body byte-for-byte.
1062
- """
1063
- counts_str = ", ".join(
1064
- f"{name} ({n})" for name, n in subsection_counts.items() if n > 0
1065
- ) or "(none)"
1066
- total = sum(subsection_counts.values())
1067
- private_body = (
1068
- f"chore(release): v{version}\n"
1069
- f"\n"
1070
- f"Stamp release v{version} over {total} [Unreleased] entries.\n"
1071
- f"\n"
1072
- f"Run by `{invocation}` from main at {prior_head[:7]}.\n"
1073
- f"Bump kind: {bump_kind}.\n"
1074
- f"Subsections stamped: {counts_str}.\n"
1075
- )
1076
- public_block = f"--- public ---\nchore(release): v{version}\n\n{body}\n"
1077
- return private_body + "\n" + public_block
1078
-
1079
-
1080
- def _release_run_phase_stamp(
1081
- version: str,
1082
- remote: str,
1083
- invocation: str,
1084
- bump_kind: str = "(unspecified)",
1085
- ) -> str:
1086
- """Phase 1 — stamp `[Unreleased]` into a `[X.Y.Z]` section and commit.
1087
-
1088
- Idempotent: if `_release_phase_stamp_done(version)` reports done,
1089
- short-circuits with the existing HEAD SHA. Otherwise rewrites
1090
- CHANGELOG.md atomically (`os.replace`), stages it, verifies the
1091
- staging cache contains exactly `CHANGELOG.md` (defends against
1092
- operator-prestaged content slipping into the release commit), then
1093
- invokes `git commit -F <msgfile> --cleanup=verbatim` so the
1094
- `### Added` / `### Fixed` headings survive (default cleanup strips
1095
- `#`-prefixed lines as comments). Returns the new commit SHA.
1096
- """
1097
- done, head_sha = _release_phase_stamp_done(version)
1098
- if done:
1099
- print(
1100
- f"release: stamp ✓ (already done — commit {head_sha[:7]})"
1101
- )
1102
- return head_sha
1103
-
1104
- print(f"release: stamp v{version}")
1105
- today = (
1106
- os.environ.get("CCTALLY_RELEASE_DATE_UTC")
1107
- or dt.datetime.now(dt.timezone.utc).date().isoformat()
1108
- )
1109
- old_text = CHANGELOG_PATH.read_text(encoding="utf-8")
1110
- try:
1111
- new_text, body = cctally._release_stamp_changelog(old_text, version, today)
1112
- except ValueError as e:
1113
- # Mirror the dry-run path's contract: refuse with exit 2 when
1114
- # `[Unreleased]` is empty (spec §3 step 4 / §11.4 row 9).
1115
- print(f"release: {e}", file=sys.stderr)
1116
- sys.exit(2)
1117
-
1118
- # Subsection counts — read from the OLD parse (before the stamp moved
1119
- # bullets out of [Unreleased]); private-body diagnostics only.
1120
- parsed = cctally._release_parse_changelog(old_text)
1121
- counts: dict[str, int] = {}
1122
- if parsed["sections"]:
1123
- for sub in parsed["sections"][0]["subsections"]:
1124
- if sub["bullets"]:
1125
- heading = sub["heading"].replace("###", "").strip()
1126
- counts[heading] = len(sub["bullets"])
1127
-
1128
- prior_head = subprocess.check_output(
1129
- ["git", "rev-parse", "HEAD"],
1130
- text=True,
1131
- cwd=str(CHANGELOG_PATH.parent),
1132
- ).strip()
1133
-
1134
- # Atomic write: write to .tmp.<pid> sibling, then os.replace(). Wrapped
1135
- # in try/finally so a failure between write_text and os.replace doesn't
1136
- # leak a stray .tmp.<pid> file next to CHANGELOG.md.
1137
- tmp = CHANGELOG_PATH.with_suffix(f".md.tmp.{os.getpid()}")
1138
- try:
1139
- tmp.write_text(new_text, encoding="utf-8")
1140
- os.replace(tmp, CHANGELOG_PATH)
1141
- finally:
1142
- try:
1143
- tmp.unlink()
1144
- except FileNotFoundError:
1145
- pass
1146
-
1147
- # Stage CHANGELOG.md only (`cwd` so the relative path resolves).
1148
- subprocess.check_call(
1149
- ["git", "add", CHANGELOG_PATH.name],
1150
- cwd=str(CHANGELOG_PATH.parent),
1151
- )
1152
-
1153
- # Co-stamp package.json (npm channel; Phase 5 reads this version field).
1154
- # Path resolved via _package_json_path() (which derives from
1155
- # CHANGELOG_PATH) so tests monkeypatching CHANGELOG_PATH to a temp
1156
- # repo automatically see the correct (absent) package.json there.
1157
- # Guarded by `.exists()` so fixture replays for old scenarios
1158
- # pre-dating the npm channel still pass — they have no package.json
1159
- # to stamp.
1160
- pj_path = cctally._package_json_path()
1161
- if pj_path.exists():
1162
- pj_old = pj_path.read_text(encoding="utf-8")
1163
- pj_new = cctally._release_stamp_package_json(pj_old, version)
1164
- pj_path.write_text(pj_new, encoding="utf-8")
1165
- subprocess.check_call(
1166
- ["git", "add", pj_path.name],
1167
- cwd=str(CHANGELOG_PATH.parent),
1168
- )
1169
-
1170
- # Trailer staging guard: refuse if anything else is staged. Defensive
1171
- # — preflight should have caught a dirty index, but operators can race
1172
- # `git add` between preflight and stamp.
1173
- staged = (
1174
- subprocess.check_output(
1175
- ["git", "diff", "--cached", "--name-only"],
1176
- text=True,
1177
- cwd=str(CHANGELOG_PATH.parent),
1178
- )
1179
- .strip()
1180
- .splitlines()
1181
- )
1182
- expected_staged = (
1183
- ["CHANGELOG.md", "package.json"]
1184
- if pj_path.exists()
1185
- else ["CHANGELOG.md"]
1186
- )
1187
- if staged != expected_staged:
1188
- print(
1189
- f"release: stamp aborted; expected only {expected_staged} staged, "
1190
- f"got {staged}",
1191
- file=sys.stderr,
1192
- )
1193
- recover_paths = " ".join(expected_staged)
1194
- print(
1195
- "release: to recover, run `git reset HEAD` and "
1196
- f"`git checkout -- {recover_paths}`",
1197
- file=sys.stderr,
1198
- )
1199
- sys.exit(3)
1200
-
1201
- msg = _release_build_stamp_message(
1202
- version, body, invocation, prior_head, bump_kind, counts
1203
- )
1204
- msg_file = CHANGELOG_PATH.parent / f".release-msg.{os.getpid()}.txt"
1205
- msg_file.write_text(msg, encoding="utf-8")
1206
- try:
1207
- subprocess.check_call(
1208
- [
1209
- "git",
1210
- "commit",
1211
- "-F",
1212
- str(msg_file),
1213
- "--cleanup=verbatim",
1214
- ],
1215
- cwd=str(CHANGELOG_PATH.parent),
1216
- )
1217
- finally:
1218
- try:
1219
- msg_file.unlink()
1220
- except FileNotFoundError:
1221
- pass
1222
-
1223
- new_sha = subprocess.check_output(
1224
- ["git", "rev-parse", "HEAD"],
1225
- text=True,
1226
- cwd=str(CHANGELOG_PATH.parent),
1227
- ).strip()
1228
- print(f"release: stamp ✓ (commit {new_sha[:7]})")
1229
- return new_sha
1230
-
1231
-
1232
- def _release_phase_tag_done(version: str, remote: str) -> bool:
1233
- """Phase-2 read-only signal: tag exists locally AND on `<remote>`.
1234
-
1235
- Both are required (spec §5.1): a local-only tag means the push step
1236
- still has work; a remote-only tag (rare — implies a manual push)
1237
- is treated as not-done so the local annotation is recreated.
1238
- Read-only — never mutates.
1239
- """
1240
- tag = f"v{version}"
1241
- local = (
1242
- subprocess.check_output(
1243
- ["git", "tag", "-l", tag],
1244
- text=True,
1245
- cwd=str(CHANGELOG_PATH.parent),
1246
- )
1247
- .strip()
1248
- .splitlines()
1249
- )
1250
- if tag not in local:
1251
- return False
1252
- try:
1253
- out = subprocess.check_output(
1254
- ["git", "ls-remote", "--tags", remote, f"refs/tags/{tag}"],
1255
- text=True,
1256
- stderr=subprocess.DEVNULL,
1257
- cwd=str(CHANGELOG_PATH.parent),
1258
- ).strip()
1259
- return bool(out)
1260
- except subprocess.CalledProcessError:
1261
- return False
1262
-
1263
-
1264
- def _release_run_phase_tag(version: str, remote: str, stamp_sha: str) -> None:
1265
- """Phase 2 — write annotated tag `vX.Y.Z` and push commit + tag.
1266
-
1267
- `stamp_sha` is the SHA returned by Phase 1; tagging is pinned to that
1268
- SHA rather than re-reading HEAD, so an unrelated commit landing on top
1269
- between phases never causes a mis-tag (defense in depth — the done-
1270
- signal in `_release_phase_stamp_done` already refuses that scenario).
1271
-
1272
- Idempotent: short-circuits when `_release_phase_tag_done`. The
1273
- body is re-parsed from CHANGELOG.md and run through
1274
- `_release_canonical_body` so the annotation is byte-identical to
1275
- Phase 1's commit-message public block (body-canonical-three-sources
1276
- invariant, spec §7.4 — same string flows into Phase 4's GH Release
1277
- notes).
1278
-
1279
- Resume scenarios:
1280
- - Local tag missing, remote tag missing: create + push as normal.
1281
- - Local tag exists, remote tag missing (push failed last run, or
1282
- operator pushed `main` manually): skip `git tag` (would conflict
1283
- with the existing local tag) and push only the tag explicitly.
1284
- - Both present: short-circuited by `_release_phase_tag_done` above.
1285
-
1286
- Signing: use `-s` only when both `user.signingkey` is set AND
1287
- `tag.gpgsign` is `true`; otherwise `-a` (annotated, unsigned).
1288
- Operators that have signing keys configured but not enabled for
1289
- tags get the unsigned path. Signed tags will have a PGP signature
1290
- block appended to the tag object — the eventual harness scenario
1291
- must strip lines from `-----BEGIN PGP SIGNATURE-----` onward
1292
- before comparing the annotation against the canonical body.
1293
-
1294
- `--cleanup=verbatim` is required: default cleanup strips
1295
- `#`-prefixed lines, eating every `### Added` / `### Fixed`
1296
- heading. Push uses `--follow-tags` to ship commit + tag in one
1297
- operation, plus an explicit `refs/tags/...:refs/tags/...` push as
1298
- belt-and-suspenders: `--follow-tags` skips tags whose target commit
1299
- is already on the remote (the resume-after-manual-push case), so
1300
- the explicit push is what guarantees the tag actually lands.
1301
- """
1302
- if _release_phase_tag_done(version, remote):
1303
- print(
1304
- f"release: tag ✓ (already done — v{version} on {remote})"
1305
- )
1306
- return
1307
-
1308
- print(f"release: tag v{version}")
1309
- tag = f"v{version}"
1310
-
1311
- # Resume-aware: if the local tag already exists from a prior run, skip
1312
- # `git tag` (would fail with "tag already exists") and push only.
1313
- local_tags = (
1314
- subprocess.check_output(
1315
- ["git", "tag", "-l", tag],
1316
- text=True,
1317
- cwd=str(CHANGELOG_PATH.parent),
1318
- )
1319
- .strip()
1320
- .splitlines()
1321
- )
1322
- if tag in local_tags:
1323
- print(f"release: tag {tag} exists locally; pushing tag only")
1324
- else:
1325
- # Body — re-parse CHANGELOG so the annotation reuses the canonical
1326
- # body string (matches the public block of Phase 1's commit byte
1327
- # for byte; same string flows into Phase 4's GH Release notes).
1328
- text = CHANGELOG_PATH.read_text(encoding="utf-8")
1329
- parsed = cctally._release_parse_changelog(text)
1330
- target_section = next(
1331
- (
1332
- s
1333
- for s in parsed["sections"]
1334
- if s["heading"].lstrip().startswith(f"## [{version}]")
1335
- ),
1336
- None,
1337
- )
1338
- if target_section is None:
1339
- print(
1340
- f"release: cannot find [{version}] section in CHANGELOG.md",
1341
- file=sys.stderr,
1342
- )
1343
- sys.exit(3)
1344
- body = cctally._release_canonical_body(target_section)
1345
-
1346
- annotation = f"{tag}\n\n{body}\n"
1347
- msg_file = CHANGELOG_PATH.parent / f".release-tag-msg.{os.getpid()}.txt"
1348
- try:
1349
- msg_file.write_text(annotation, encoding="utf-8")
1350
- signing_key = subprocess.run(
1351
- ["git", "config", "--get", "user.signingkey"],
1352
- capture_output=True,
1353
- text=True,
1354
- cwd=str(CHANGELOG_PATH.parent),
1355
- ).stdout.strip()
1356
- tag_gpgsign = (
1357
- subprocess.run(
1358
- ["git", "config", "--get", "tag.gpgsign"],
1359
- capture_output=True,
1360
- text=True,
1361
- cwd=str(CHANGELOG_PATH.parent),
1362
- )
1363
- .stdout.strip()
1364
- .lower()
1365
- == "true"
1366
- )
1367
- sign_flag = "-s" if (signing_key and tag_gpgsign) else "-a"
1368
- subprocess.check_call(
1369
- [
1370
- "git",
1371
- "tag",
1372
- sign_flag,
1373
- "-F",
1374
- str(msg_file),
1375
- "--cleanup=verbatim",
1376
- tag,
1377
- stamp_sha,
1378
- ],
1379
- cwd=str(CHANGELOG_PATH.parent),
1380
- )
1381
- finally:
1382
- try:
1383
- msg_file.unlink()
1384
- except FileNotFoundError:
1385
- pass
1386
-
1387
- branch = subprocess.check_output(
1388
- ["git", "rev-parse", "--abbrev-ref", "HEAD"],
1389
- text=True,
1390
- cwd=str(CHANGELOG_PATH.parent),
1391
- ).strip()
1392
- subprocess.check_call(
1393
- ["git", "push", remote, branch, "--follow-tags"],
1394
- cwd=str(CHANGELOG_PATH.parent),
1395
- )
1396
- # Belt-and-suspenders: --follow-tags skips tags whose target is already
1397
- # on the remote (e.g., resume after operator pushed `main` manually).
1398
- # Explicit refs-spec always pushes the tag; no-op when both are already
1399
- # on remote (true resume-after-success).
1400
- subprocess.check_call(
1401
- ["git", "push", remote, f"refs/tags/{tag}:refs/tags/{tag}"],
1402
- cwd=str(CHANGELOG_PATH.parent),
1403
- )
1404
- print(f"release: tag ✓ (annotated, pushed to {remote})")
1405
-
1406
-
1407
- def _release_discover_public_clone(args: argparse.Namespace) -> pathlib.Path:
1408
- """Resolve the public-clone path (spec §9.1).
1409
-
1410
- Priority chain (highest first):
1411
- 1. ``--public-clone <path>`` flag.
1412
- 2. ``git config --get release.publicClone``.
1413
- 3. ``$APP_DIR/release-public-clone-path`` plain-text marker file.
1414
-
1415
- Each source is silently skipped when missing (unset config key, no
1416
- marker file, no flag). Refuses with exit 2 only when ALL three
1417
- sources are absent — operator setup is one-time and explicit, no
1418
- silent fallback to a hard-coded path.
1419
- """
1420
- candidates: list[tuple[str, pathlib.Path]] = []
1421
-
1422
- if getattr(args, "public_clone", None):
1423
- candidates.append(("--public-clone", pathlib.Path(args.public_clone)))
1424
-
1425
- # Source 2 — git config. `git config --get` exits 1 when the key is
1426
- # unset; that's the silent-skip path. Other failures (e.g., not in a
1427
- # git repo) also fall through silently — preflight has already
1428
- # established we're inside a repo.
1429
- #
1430
- # Key is `release.publicClone` (camelCase) per git config naming
1431
- # conventions; git 2.46+ rejects underscore-bearing keys at write
1432
- # time, so the spec's informal `release.public_clone` wording maps
1433
- # to this canonical form. Git config lookup is case-insensitive on
1434
- # the trailing variable, so `release.publicclone` works too.
1435
- try:
1436
- out = subprocess.run(
1437
- ["git", "config", "--get", "release.publicClone"],
1438
- capture_output=True,
1439
- text=True,
1440
- check=True,
1441
- ).stdout.strip()
1442
- if out:
1443
- candidates.append(
1444
- ("git config release.publicClone", pathlib.Path(out))
1445
- )
1446
- except subprocess.CalledProcessError:
1447
- pass
1448
-
1449
- # Source 3 — marker file under APP_DIR.
1450
- marker = APP_DIR / "release-public-clone-path"
1451
- if marker.exists():
1452
- text = marker.read_text(encoding="utf-8").strip()
1453
- if text:
1454
- candidates.append((str(marker), pathlib.Path(text)))
1455
-
1456
- for _source, path in candidates:
1457
- if (path / ".git").exists():
1458
- return path.resolve()
1459
- # Bare repo — `<path>/HEAD` exists alongside `objects/`, `refs/`.
1460
- if path.is_dir() and (path / "HEAD").exists():
1461
- return path.resolve()
1462
- # Lenient fallthrough — if the path simply exists but doesn't
1463
- # match either layout, return it anyway. The next subprocess
1464
- # (`git -C <path> ...`) will fail loudly with a clear error;
1465
- # pre-validating here would only duplicate that diagnostic.
1466
- if path.exists():
1467
- return path.resolve()
1468
-
1469
- marker_path = APP_DIR / "release-public-clone-path"
1470
- print(
1471
- "release: cannot discover public clone path; pass --public-clone "
1472
- "<path>, set 'release.publicClone' in git config, or write the "
1473
- f"path to {marker_path}",
1474
- file=sys.stderr,
1475
- )
1476
- sys.exit(2)
1477
-
1478
-
1479
- def _release_discover_brew_clone(
1480
- args: argparse.Namespace,
1481
- ) -> pathlib.Path | None:
1482
- """Resolve the brew tap clone path. Returns ``None`` if unconfigured.
1483
-
1484
- Priority chain (mirrors :func:`_release_discover_public_clone`):
1485
- 1. ``--brew-clone <path>`` flag.
1486
- 2. ``git config --get release.brewClone``.
1487
- 3. ``$APP_DIR/release-brew-clone-path`` plain-text marker file.
1488
-
1489
- Unlike :func:`_release_discover_public_clone` (which exits 2 when
1490
- none of the sources resolve), this returns ``None`` — Phase 6 is a
1491
- graceful skip, not a hard refusal, since release channels are
1492
- added incrementally and not every operator has a brew tap clone
1493
- on hand.
1494
- """
1495
- if getattr(args, "brew_clone", None):
1496
- return pathlib.Path(args.brew_clone).expanduser()
1497
-
1498
- cfg = subprocess.run(
1499
- ["git", "config", "--get", "release.brewClone"],
1500
- capture_output=True,
1501
- text=True,
1502
- check=False,
1503
- )
1504
- if cfg.returncode == 0 and cfg.stdout.strip():
1505
- return pathlib.Path(cfg.stdout.strip()).expanduser()
1506
-
1507
- marker = APP_DIR / "release-brew-clone-path"
1508
- if marker.exists():
1509
- path_str = marker.read_text(encoding="utf-8").strip()
1510
- if path_str:
1511
- return pathlib.Path(path_str).expanduser()
1512
-
1513
- return None
1514
-
1515
-
1516
- def _release_phase_brew_done(
1517
- version: str, brew_clone: pathlib.Path
1518
- ) -> bool:
1519
- """Phase 6 done iff the brew tap **remote** serves the ``vX.Y.Z``
1520
- formula on its default branch AND carries the ``v<version>`` tag.
1521
-
1522
- Tag presence alone is not enough — ``brew install`` reads the
1523
- formula from the tap's default branch, NOT from the tag, so a
1524
- half-failed push (tag landed, branch did not) would still serve
1525
- the prior formula to users. Mirrors the
1526
- :func:`_release_phase_mirror_done` pattern (Phase 3) and adds a
1527
- branch-tip check on top.
1528
-
1529
- Three-step check (each gates the next):
1530
-
1531
- 1. Local-file sniff — cheap pre-check; skip the network when
1532
- the formula isn't even staged locally.
1533
- 2. ``git ls-remote --tags origin refs/tags/v<version>`` — tag
1534
- must be on the remote.
1535
- 3. ``git ls-remote origin refs/heads/<branch>`` SHA == local
1536
- clone's ``HEAD`` SHA — the local formula commit must be the
1537
- remote default-branch tip. After a successful Phase 6 push,
1538
- these match; after a half-failed push (branch fail, tag
1539
- succeed), they diverge.
1540
-
1541
- Returns ``False`` on any subprocess failure (no origin configured,
1542
- network glitch); the caller proceeds to run the phase, whose own
1543
- push step is independently idempotent.
1544
- """
1545
- formula = brew_clone / "Formula" / "cctally.rb"
1546
- if not formula.exists():
1547
- return False
1548
- if f"/v{version}.tar.gz" not in formula.read_text(encoding="utf-8"):
1549
- return False
1550
- try:
1551
- origin = subprocess.check_output(
1552
- ["git", "-C", str(brew_clone), "remote", "get-url", "origin"],
1553
- text=True,
1554
- ).strip()
1555
- tag_out = subprocess.check_output(
1556
- ["git", "ls-remote", "--tags", origin, f"refs/tags/v{version}"],
1557
- text=True,
1558
- ).strip()
1559
- if not tag_out:
1560
- return False
1561
- local_head = subprocess.check_output(
1562
- ["git", "-C", str(brew_clone), "rev-parse", "HEAD"],
1563
- text=True,
1564
- ).strip()
1565
- branch = subprocess.check_output(
1566
- ["git", "-C", str(brew_clone), "rev-parse", "--abbrev-ref", "HEAD"],
1567
- text=True,
1568
- ).strip()
1569
- head_out = subprocess.check_output(
1570
- ["git", "ls-remote", origin, f"refs/heads/{branch}"],
1571
- text=True,
1572
- ).strip()
1573
- # ls-remote line format: "<sha>\trefs/heads/<branch>"; empty
1574
- # output means the remote has no such branch (fresh tap).
1575
- remote_head = head_out.split("\t", 1)[0] if head_out else ""
1576
- return remote_head == local_head
1577
- except subprocess.CalledProcessError:
1578
- return False
1579
-
1580
-
1581
- def _release_phase_mirror_done(
1582
- version: str, public_clone: pathlib.Path
1583
- ) -> bool:
1584
- """Phase 3 done iff the public clone's ``origin`` carries ``vX.Y.Z``.
1585
-
1586
- Read-only signal — runs ``git ls-remote`` against the public clone's
1587
- own ``origin`` (the public mirror's URL is the single source of
1588
- truth here, per spec §9.1). Returns ``False`` on any subprocess
1589
- failure (no origin remote configured, network glitch, etc.); the
1590
- caller proceeds to run all three sub-steps, each idempotent on
1591
- its own.
1592
- """
1593
- tag = f"v{version}"
1594
- try:
1595
- public_origin = subprocess.check_output(
1596
- ["git", "-C", str(public_clone), "remote", "get-url", "origin"],
1597
- text=True,
1598
- ).strip()
1599
- out = subprocess.check_output(
1600
- ["git", "ls-remote", "--tags", public_origin, f"refs/tags/{tag}"],
1601
- text=True,
1602
- ).strip()
1603
- return bool(out)
1604
- except subprocess.CalledProcessError:
1605
- return False
1606
-
1607
-
1608
- def _release_run_phase_mirror(
1609
- version: str, public_clone: pathlib.Path, remote: str
1610
- ) -> None:
1611
- """Phase 3 — replay private commits onto the public clone, then push
1612
- the branch + tag (spec §9.1).
1613
-
1614
- Three sub-steps, each its own subprocess; any failure halts with
1615
- exit 3 so ``--resume`` can re-run from the failed step (each
1616
- sub-step is independently idempotent).
1617
-
1618
- 3a. ``bin/cctally-mirror-public --yes <public-clone>`` — replay
1619
- private commits onto the local public clone. ``--yes`` is
1620
- mandatory in non-interactive context (the mirror tool prompts
1621
- ``apply? [y/N]`` otherwise).
1622
- 3b. ``git -C <public-clone> push origin <branch>`` — push the
1623
- public-clone branch to public origin. The branch is read
1624
- dynamically via ``git -C <public-clone> rev-parse --abbrev-ref
1625
- HEAD`` rather than hardcoded ``main``, since some operators
1626
- may run their public clone on a non-default branch.
1627
- 3c. ``git -C <public-clone> push origin refs/tags/v<version>``
1628
- — push the new tag.
1629
-
1630
- The ``remote`` arg is currently unused (the public-clone push always
1631
- targets the clone's own ``origin``); kept in the signature for
1632
- parity with ``_release_run_phase_tag`` and possible future
1633
- multi-remote scenarios.
1634
- """
1635
- del remote # spec §9.1 — public-clone push always targets `origin`.
1636
- if _release_phase_mirror_done(version, public_clone):
1637
- print(
1638
- f"release: mirror ✓ (already done — v{version} on public origin)"
1639
- )
1640
- return
1641
-
1642
- print("release: mirror push")
1643
-
1644
- # Locate `bin/cctally-mirror-public` alongside this script. Resolve
1645
- # via __file__ so symlinked invocations (~/.local/bin/cctally) still
1646
- # find the in-repo sibling.
1647
- mirror_tool = (
1648
- pathlib.Path(__file__).resolve().parent / "cctally-mirror-public"
1649
- )
1650
- if not mirror_tool.exists():
1651
- print(
1652
- f"release: cannot find {mirror_tool}",
1653
- file=sys.stderr,
1654
- )
1655
- sys.exit(3)
1656
-
1657
- # Step 3a — replay private commits onto the public clone.
1658
- rc = subprocess.call(
1659
- [str(mirror_tool), "--yes", "--public-clone", str(public_clone)]
1660
- )
1661
- if rc != 0:
1662
- print(
1663
- f"release: mirror replay (3a) failed (exit {rc}); "
1664
- "see output above",
1665
- file=sys.stderr,
1666
- )
1667
- sys.exit(3)
1668
-
1669
- # Step 3b — push the public-clone branch to public origin. The branch
1670
- # is read dynamically (don't hardcode `main`).
1671
- try:
1672
- branch = subprocess.check_output(
1673
- ["git", "-C", str(public_clone), "rev-parse", "--abbrev-ref", "HEAD"],
1674
- text=True,
1675
- ).strip()
1676
- except subprocess.CalledProcessError as e:
1677
- print(
1678
- f"release: mirror branch lookup failed (exit {e.returncode})",
1679
- file=sys.stderr,
1680
- )
1681
- sys.exit(3)
1682
- rc = subprocess.call(
1683
- ["git", "-C", str(public_clone), "push", "origin", branch]
1684
- )
1685
- if rc != 0:
1686
- print(
1687
- f"release: mirror push branch (3b) failed (exit {rc})",
1688
- file=sys.stderr,
1689
- )
1690
- sys.exit(3)
1691
-
1692
- # Step 3c — push the new tag.
1693
- tag = f"v{version}"
1694
- rc = subprocess.call(
1695
- [
1696
- "git", "-C", str(public_clone),
1697
- "push", "origin", f"refs/tags/{tag}",
1698
- ]
1699
- )
1700
- if rc != 0:
1701
- print(
1702
- f"release: mirror push tag (3c) failed (exit {rc})",
1703
- file=sys.stderr,
1704
- )
1705
- sys.exit(3)
1706
-
1707
- print(f"release: mirror ✓ (v{version} propagated)")
1708
-
1709
-
1710
- def _release_phase_gh_done(version: str) -> bool:
1711
- """Phase-4 read-only signal: ``gh release view vX.Y.Z`` returns 0.
1712
-
1713
- Read-only — issues a `gh release view` against the public repo and
1714
- treats exit 0 as "release exists." Suppresses stderr/stdout because
1715
- the call is purely a probe; the operator-visible state is recorded
1716
- in ``_release_run_phase_gh``'s own logging. A never-authed operator
1717
- sees this return False (gh exits non-zero on auth error), the helper
1718
- falls through to its own auth probe, and the fallback path prints a
1719
- copy-pasteable command — same UX whether the release simply doesn't
1720
- exist yet or whether gh can't see it.
1721
- """
1722
- rc = subprocess.call(
1723
- ["gh", "release", "view", f"v{version}", "--repo", PUBLIC_REPO],
1724
- stdout=subprocess.DEVNULL,
1725
- stderr=subprocess.DEVNULL,
1726
- )
1727
- return rc == 0
1728
-
1729
-
1730
- def _release_phase_npm_done(version: str) -> bool:
1731
- """True iff ``cctally@<version>`` is already published on npm.
1732
-
1733
- Probes via ``npm view cctally@<v> dist.tarball --json``. Returns
1734
- True when the command succeeds AND stdout is a JSON-quoted
1735
- registry.npmjs.org URL. False on any other condition (timeout,
1736
- npm not on PATH, version absent, etc.). Used as the idempotency
1737
- short-circuit for Phase 5 (parity with ``_release_phase_gh_done``).
1738
- """
1739
- try:
1740
- result = subprocess.run(
1741
- ["npm", "view", f"cctally@{version}", "dist.tarball", "--json"],
1742
- capture_output=True,
1743
- text=True,
1744
- check=False,
1745
- timeout=15,
1746
- )
1747
- except (subprocess.TimeoutExpired, FileNotFoundError):
1748
- return False
1749
- if result.returncode != 0:
1750
- return False
1751
- out = result.stdout.strip()
1752
- return (
1753
- out.startswith('"')
1754
- and out.endswith('"')
1755
- and "registry.npmjs.org" in out
1756
- )
1757
-
1758
-
1759
- def _release_run_phase_gh(version: str, body: str) -> int:
1760
- """Phase 4 — ``gh release create`` with auth fallback (spec §9.2).
1761
-
1762
- Returns:
1763
- - ``0`` on successful publish OR auth-fallback (don't fail the
1764
- whole release on missing gh auth — phases 1-3 already succeeded;
1765
- Phase 4 is polish).
1766
- - ``3`` on hard failure of ``gh release create`` after auth was
1767
- confirmed OK (network glitch, server-side rejection, etc.); the
1768
- operator can address it then re-run ``cctally release --resume``.
1769
-
1770
- Idempotent: ``_release_phase_gh_done`` short-circuits when the
1771
- release already exists. The body is passed in (not re-fetched here);
1772
- the caller is responsible for extracting it from CHANGELOG so the
1773
- body-canonical-three-sources invariant (spec §7.4) is preserved
1774
- across phases 1, 2, and 4.
1775
-
1776
- Auth-mismatch semantics (spec §9.3): if ``gh release view`` finds
1777
- an existing release whose body differs from the current CHANGELOG
1778
- section, treat the existing as authoritative — no ``gh release
1779
- edit`` rewrite. Body divergence after the fact is a separate
1780
- concern that warrants explicit operator action.
1781
- """
1782
- if _release_phase_gh_done(version):
1783
- url = f"https://github.com/{PUBLIC_REPO}/releases/tag/v{version}"
1784
- print(f"release: gh release ✓ (already published — {url})")
1785
- return 0
1786
-
1787
- print("release: gh release")
1788
-
1789
- # Auth probe — both must succeed for the operator to be able to
1790
- # write to the public repo.
1791
- auth_status_ok = (
1792
- subprocess.call(
1793
- ["gh", "auth", "status", "--hostname", "github.com"],
1794
- stdout=subprocess.DEVNULL,
1795
- stderr=subprocess.DEVNULL,
1796
- )
1797
- == 0
1798
- )
1799
- repo_access_ok = (
1800
- subprocess.call(
1801
- ["gh", "api", f"repos/{PUBLIC_REPO}"],
1802
- stdout=subprocess.DEVNULL,
1803
- stderr=subprocess.DEVNULL,
1804
- )
1805
- == 0
1806
- )
1807
- if not (auth_status_ok and repo_access_ok):
1808
- cctally._release_print_gh_fallback(version, body)
1809
- return 0
1810
-
1811
- # Happy path. Notes go through a tmpfile to dodge shell escaping.
1812
- notes_file = pathlib.Path(tempfile.gettempdir()) / (
1813
- f"release-notes-v{version}-{os.getpid()}.md"
1814
- )
1815
- notes_file.write_text(body, encoding="utf-8")
1816
- try:
1817
- cmd = [
1818
- "gh", "release", "create", f"v{version}",
1819
- "--repo", PUBLIC_REPO,
1820
- "--title", f"v{version}",
1821
- "--notes-file", str(notes_file),
1822
- ]
1823
- if "-" in version:
1824
- cmd.append("--prerelease")
1825
- rc = subprocess.call(cmd)
1826
- if rc != 0:
1827
- print(
1828
- f"release: gh release create failed (exit {rc}); "
1829
- "--resume to retry",
1830
- file=sys.stderr,
1831
- )
1832
- return 3
1833
- finally:
1834
- notes_file.unlink(missing_ok=True)
1835
-
1836
- url = f"https://github.com/{PUBLIC_REPO}/releases/tag/v{version}"
1837
- print(f"release: gh release ✓ ({url})")
1838
- return 0
1839
-
1840
-
1841
- def _release_run_phase_npm(
1842
- version: str,
1843
- public_clone: pathlib.Path,
1844
- *,
1845
- dist_tag: str,
1846
- ) -> int:
1847
- """Phase 5 — wait for the public-repo GHA workflow to publish ``cctally@<v>``.
1848
-
1849
- Phase 3 pushes ``v<version>`` to ``omrikais/cctally``; the workflow at
1850
- ``.github/workflows/release-npm.yml`` fires on tag-push and runs
1851
- ``npm publish --provenance`` via OIDC trusted publisher (no NPM_TOKEN,
1852
- no operator 2FA round-trip — fixes the passkey-in-subprocess failure
1853
- mode where npm 2FA blocks ``npm publish`` from a non-interactive
1854
- subprocess).
1855
-
1856
- Phase 5 here is observation-only: poll ``npm view cctally@<v>`` until
1857
- it appears, with timeout. ``cctally`` never invokes ``npm publish``
1858
- locally anymore — Trusted Publisher binds the right to publish to the
1859
- public-repo workflow, not to the operator's `npm login` token.
1860
-
1861
- Returns ``0`` on observed success OR poll-timeout (soft-success: phases
1862
- 1-4 landed; the workflow is either succeeding or visibly failing on
1863
- github.com; ``--resume`` re-checks the registry).
1864
-
1865
- Timing overridable via ``CCTALLY_RELEASE_NPM_POLL_TIMEOUT_S`` and
1866
- ``CCTALLY_RELEASE_NPM_POLL_INTERVAL_S`` env vars.
1867
- """
1868
- print(f"phase 5: await npm publish via GHA (tag={dist_tag})")
1869
- if _release_phase_npm_done(version):
1870
- print(f" cctally@{version} already on npm — skipping.")
1871
- return 0
1872
-
1873
- timeout_s, interval_s = cctally._release_npm_poll_timing()
1874
- deadline = time.monotonic() + timeout_s
1875
- while True:
1876
- if _release_phase_npm_done(version):
1877
- print(f" cctally@{version} on npm registry ✓")
1878
- return 0
1879
- if time.monotonic() >= deadline:
1880
- print(
1881
- f"\n timed out after {timeout_s:.0f}s waiting for "
1882
- f"cctally@{version} on npm. The GHA workflow may still be "
1883
- f"running or have failed — check:\n"
1884
- f" https://github.com/{PUBLIC_REPO}/actions\n"
1885
- f" Re-run `cctally release --resume` once the workflow "
1886
- f"completes, or for emergency manual publish:\n"
1887
- f" cd {public_clone} && npm publish --access public "
1888
- f"--tag {dist_tag}\n",
1889
- file=sys.stderr,
1890
- )
1891
- return 0
1892
- time.sleep(interval_s)
1893
-
1894
-
1895
- def _release_run_phase_brew(
1896
- version: str,
1897
- brew_clone: pathlib.Path | None,
1898
- allow_downgrade: bool = False,
1899
- ) -> int:
1900
- """Phase 6 — render ``Formula/cctally.rb`` and push to the brew tap.
1901
-
1902
- Idempotent. Pre-releases are skipped at the cmd_release call site —
1903
- this function never runs for them.
1904
-
1905
- Phase semantics:
1906
- - ``brew_clone is None`` — graceful skip (no error). Operator can
1907
- opt in later by setting ``release.brewClone`` in git config.
1908
- - Done-signal short-circuit — prints "already at vX.Y.Z" and
1909
- returns 0 when ``Formula/cctally.rb`` already references this
1910
- version (idempotency under ``--resume``).
1911
- - Dirty working tree — refuses with exit 2 and points the operator
1912
- at ``--resume``.
1913
- - **Monotonic-version gate (issue #30).** Refuses with exit 2 when
1914
- the existing on-disk formula's URL pins a *higher* SemVer than
1915
- ``version``. Catches the f02b2f1 regression class — operator
1916
- runs Phase 6 from a stale CHANGELOG / fixture leak / accidental
1917
- old branch and would otherwise silently write a lower version
1918
- over a higher one. Override via ``allow_downgrade=True``
1919
- (operator-driven, for genuine yank/revert cases).
1920
- - Push failure — auth-fallback parity with Phase 4: prints the
1921
- exact recovery command and returns 0. Phases 1-5 already
1922
- succeeded, the release IS published from the user's
1923
- perspective; the brew tap is the third channel and treated as
1924
- polish.
1925
-
1926
- Returns:
1927
- - ``0`` on success, graceful skip, idempotent short-circuit, OR
1928
- push fallback.
1929
- - ``2`` on dirty-working-tree refusal OR downgrade-gate refusal.
1930
- """
1931
- print("phase 6: brew formula bump")
1932
- if brew_clone is None:
1933
- print(
1934
- " brew tap clone not configured. Set with:\n"
1935
- " git config release.brewClone /path/to/homebrew-cctally\n"
1936
- " Skipping phase 6 (no error).",
1937
- file=sys.stderr,
1938
- )
1939
- return 0
1940
- if _release_phase_brew_done(version, brew_clone):
1941
- print(f" Formula/cctally.rb already at v{version} on tap — skipping.")
1942
- return 0
1943
-
1944
- # Refuse on dirty working tree — we're about to write the formula
1945
- # and commit; mixing operator-staged work into our commit is a
1946
- # footgun. Resume after the operator resolves.
1947
- status = subprocess.run(
1948
- ["git", "-C", str(brew_clone), "status", "--porcelain"],
1949
- capture_output=True,
1950
- text=True,
1951
- check=True,
1952
- )
1953
- if status.stdout.strip():
1954
- print(
1955
- f" brew clone has uncommitted changes:\n{status.stdout}\n"
1956
- " Resolve and re-run `cctally release --resume`.",
1957
- file=sys.stderr,
1958
- )
1959
- return 2
1960
-
1961
- formula_path = brew_clone / "Formula" / "cctally.rb"
1962
- local_at_version = (
1963
- formula_path.exists()
1964
- and f"/v{version}.tar.gz"
1965
- in formula_path.read_text(encoding="utf-8")
1966
- )
1967
-
1968
- if local_at_version:
1969
- # Resume after a push failure (the done-check verifies remote
1970
- # tag, so we only reach here when the local commit is in place
1971
- # but the tap origin hasn't seen it). Skip render + commit; go
1972
- # straight to (re)tag + push. Re-rendering would no-op the
1973
- # commit (`git commit` exits 1 with nothing to commit), so
1974
- # short-circuiting is also what keeps the function tidy.
1975
- print(
1976
- f" local formula already at v{version}; re-pushing to tap…"
1977
- )
1978
- else:
1979
- # Monotonic-version gate (issue #30). The brew tap regressed
1980
- # from v1.3.0 → v1.0.0 twice in one day via this code path; the
1981
- # equality fingerprint above (`local_at_version`) is False on a
1982
- # downgrade, so without this gate we'd silently overwrite a
1983
- # higher version. Compare the existing formula's URL-pinned
1984
- # SemVer against `version` (SemVer-aware so prereleases sort
1985
- # below their stable counterpart per §11.4); refuse with exit 2
1986
- # when the on-disk version is strictly higher. Unparseable
1987
- # formulas are treated as unversioned and allowed through.
1988
- if formula_path.exists():
1989
- existing_text = formula_path.read_text(encoding="utf-8")
1990
- existing_v = cctally._release_extract_formula_version(existing_text)
1991
- if existing_v is not None:
1992
- try:
1993
- existing_key = _release_semver_sort_key(
1994
- _release_parse_semver(existing_v)
1995
- )
1996
- target_key = _release_semver_sort_key(
1997
- _release_parse_semver(version)
1998
- )
1999
- except ValueError:
2000
- existing_key = target_key = None
2001
- if (
2002
- existing_key is not None
2003
- and target_key is not None
2004
- and existing_key > target_key
2005
- and not allow_downgrade
2006
- ):
2007
- print(
2008
- f" refuse: existing formula pins v{existing_v}, "
2009
- f"target is v{version} (downgrade).\n"
2010
- " Common causes: stale CHANGELOG.md in this clone, "
2011
- "fixture leak into a real `release.brewClone`, or an "
2012
- "accidental old branch.\n"
2013
- " Verify intent, then re-run with "
2014
- "`--allow-formula-downgrade` to override "
2015
- "(yank / revert cases). See issue #30.",
2016
- file=sys.stderr,
2017
- )
2018
- return 2
2019
- if (
2020
- existing_key is not None
2021
- and target_key is not None
2022
- and existing_key > target_key
2023
- and allow_downgrade
2024
- ):
2025
- print(
2026
- f" WARNING: writing v{version} over existing v{existing_v} "
2027
- "(--allow-formula-downgrade); intentional yank?",
2028
- file=sys.stderr,
2029
- )
2030
- print(f" computing sha256 of v{version} archive…")
2031
- sha = cctally._release_compute_brew_sha256(version)
2032
-
2033
- template = cctally._homebrew_template_path().read_text(encoding="utf-8")
2034
- rendered = (
2035
- template
2036
- .replace("<<VERSION>>", version)
2037
- .replace("<<SHA256>>", sha)
2038
- )
2039
- formula_path.parent.mkdir(parents=True, exist_ok=True)
2040
- formula_path.write_text(rendered, encoding="utf-8")
2041
-
2042
- subprocess.run(
2043
- ["git", "-C", str(brew_clone), "add", "Formula/cctally.rb"],
2044
- check=True,
2045
- )
2046
- subprocess.run(
2047
- ["git", "-C", str(brew_clone), "commit", "-m",
2048
- f"chore(formula): cctally {version}"],
2049
- check=True,
2050
- )
2051
- # Tag is best-effort — re-running after a partial publish should
2052
- # not fail just because the tag already exists locally.
2053
- #
2054
- # Annotated form with `-m` (issue #25): plain `git tag <name>` is
2055
- # silently upgraded to `git tag -s <name>` under operator-global
2056
- # `tag.gpgsign=true` and demands a message via editor. The release
2057
- # script has no editor stdin, so git aborts with `fatal: no tag
2058
- # message?` — the atomic push refspec then fails with "src refspec
2059
- # does not match any" because the local tag was never created, and
2060
- # the auth-fallback branch below silently swallows the failure as
2061
- # exit 0. Mirrors Phase 2's signing detection (signing_key +
2062
- # tag.gpgsign → -s, else fall back), with one defensive divergence:
2063
- # the fallback uses --no-sign (not bare -a) so the tag still lands
2064
- # under tag.gpgsign=true without a usable signing key configured.
2065
- # Brew install reads the formula off the tap's default branch, not
2066
- # the tag, so signing the tap tag is operationally moot — it exists
2067
- # for history bookkeeping and atomic-push transport.
2068
- signing_key = subprocess.run(
2069
- ["git", "-C", str(brew_clone), "config", "--get", "user.signingkey"],
2070
- capture_output=True,
2071
- text=True,
2072
- ).stdout.strip()
2073
- tag_gpgsign = (
2074
- subprocess.run(
2075
- ["git", "-C", str(brew_clone), "config", "--get", "tag.gpgsign"],
2076
- capture_output=True,
2077
- text=True,
2078
- )
2079
- .stdout.strip()
2080
- .lower()
2081
- == "true"
2082
- )
2083
- sign_flag = "-s" if (signing_key and tag_gpgsign) else "--no-sign"
2084
- subprocess.run(
2085
- ["git", "-C", str(brew_clone), "tag", sign_flag, "-a", "-m",
2086
- f"cctally v{version}", f"v{version}"],
2087
- check=False,
2088
- )
2089
- # Single ATOMIC push of branch + tag in one transaction — the
2090
- # remote either accepts both refs or neither (server-side atomic
2091
- # push, `--atomic`). Avoids the half-failed-push asymmetry that a
2092
- # split branch-push + tag-push pair admits: a tag landing without
2093
- # the branch landing would leave `brew install` serving the OLD
2094
- # formula off the tap's default branch even though the remote
2095
- # carries the new tag. Tag refspec is explicit (`src:dst`) for
2096
- # atomic-push semantics — `--atomic` requires named refs, not the
2097
- # implicit `--follow-tags` path.
2098
- push = subprocess.run(
2099
- ["git", "-C", str(brew_clone), "push", "--atomic", "origin",
2100
- "HEAD", f"refs/tags/v{version}:refs/tags/v{version}"],
2101
- check=False,
2102
- )
2103
- if push.returncode != 0:
2104
- # If the local tag isn't there (e.g., the operator hit the
2105
- # tag.gpgsign edge case from issue #25 even with the fix), the
2106
- # plain push refspec fails. Surface a tag-create fallback in
2107
- # the hint so copy-paste recovery is self-contained.
2108
- print(
2109
- f"\n push failed. Manual recovery:\n"
2110
- f" # If local tag v{version} is missing (e.g., gpgsign issue):\n"
2111
- f" git -C {brew_clone} tag --no-sign -a -m \"cctally v{version}\" v{version}\n"
2112
- f" # Then push:\n"
2113
- f" git -C {brew_clone} push --atomic origin HEAD "
2114
- f"refs/tags/v{version}:refs/tags/v{version}\n",
2115
- file=sys.stderr,
2116
- )
2117
- return 0 # auth-fallback semantics; parity with Phase 4.
2118
-
2119
- return 0
2120
-
2121
-
2122
- def _release_extract_body_from_changelog(version: str) -> str:
2123
- """Re-read the canonical body for ``version`` from CHANGELOG.md.
2124
-
2125
- Same parse + canonicalize path as Phases 1 and 2 (spec §7.4 — the
2126
- body-canonical-three-sources invariant). Refuses with exit 3 if
2127
- the section isn't found; ``--resume`` is the recovery once the
2128
- operator re-stamps.
2129
- """
2130
- text = CHANGELOG_PATH.read_text(encoding="utf-8")
2131
- parsed = cctally._release_parse_changelog(text)
2132
- section = next(
2133
- (
2134
- s for s in parsed["sections"]
2135
- if s["heading"].startswith(f"## [{version}]")
2136
- ),
2137
- None,
2138
- )
2139
- if section is None:
2140
- print(
2141
- f"release: cannot find [{version}] section in CHANGELOG.md",
2142
- file=sys.stderr,
2143
- )
2144
- sys.exit(3)
2145
- return cctally._release_canonical_body(section)
2146
-
2147
-
2148
- def cmd_release(args: argparse.Namespace) -> int:
2149
- """Dispatch thunk — actual implementation in bin/_cctally_release.py.
2150
-
2151
- Lazy-load + invoke. Test/harness code that calls cctally.cmd_release
2152
- directly hits this thunk; the underlying _cctally_release.cmd_release
2153
- handles the work. The thunk fires only when cmd_release is invoked,
2154
- so the lazy module's parse cost is paid only on `cctally release ...`
2155
- or test access — not on every `cctally <other-subcmd>` invocation.
2156
-
2157
- Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §4.1
2158
- """
2159
- return _load_sibling("_cctally_release").cmd_release(args)
810
+ # `cmd_release` is NOT defined here: the release subcommand was extracted
811
+ # from the main `cctally` CLI and lives as a standalone entry point
812
+ # (bin/cctally-release). The implementation is in bin/_cctally_release.py.
813
+ # Tests that exercise `cmd_release` import it directly from
814
+ # `_cctally_release`.
2160
815
 
2161
816
 
2162
817
  # Files to scan when detecting the legacy status-line snippet (Section 5).
@@ -2331,7 +986,6 @@ def _decode_escaped_cwd(dir_name: str) -> str:
2331
986
  return "/" + stripped.replace("-", "/")
2332
987
 
2333
988
 
2334
-
2335
989
  def _sum_cost_for_range(
2336
990
  start: dt.datetime,
2337
991
  end: dt.datetime,
@@ -2414,7 +1068,6 @@ def _resolve_primary_model_for_block(
2414
1068
  return row["model"]
2415
1069
 
2416
1070
 
2417
-
2418
1071
  def _read_keychain_oauth_blob() -> str | None:
2419
1072
  """Read the Claude Code keychain entry on macOS via `security`.
2420
1073
 
@@ -5276,7 +3929,6 @@ def _parse_cli_date_range(
5276
3929
  return range_start, range_end
5277
3930
 
5278
3931
 
5279
-
5280
3932
  def cmd_daily(args: argparse.Namespace) -> int:
5281
3933
  """Show usage report grouped by display-timezone date."""
5282
3934
  _share_validate_args(args)
@@ -6475,7 +5127,6 @@ _diff_render_json = _lib_diff_kernel._diff_render_json
6475
5127
  _diff_resolve_anchor = _lib_diff_kernel._diff_resolve_anchor
6476
5128
 
6477
5129
 
6478
-
6479
5130
  def cmd_diff(args: argparse.Namespace) -> int:
6480
5131
  """Compare Claude usage between two windows."""
6481
5132
  now_utc = _command_as_of()
@@ -10209,7 +8860,7 @@ def doctor_gather_state(
10209
8860
  )
10210
8861
 
10211
8862
  # ── Meta ─────────────────────────────────────────────────────────
10212
- cctally_version_tuple = _release_read_latest_release_version()
8863
+ cctally_version_tuple = _lib_changelog._read_latest_changelog_version()
10213
8864
  cctally_version = (
10214
8865
  cctally_version_tuple[0] if cctally_version_tuple else "unknown"
10215
8866
  )
@@ -11929,90 +10580,9 @@ def build_parser() -> argparse.ArgumentParser:
11929
10580
  )
11930
10581
  doctor_p.set_defaults(func=cmd_doctor)
11931
10582
 
11932
- # ---- release (issue #24 release automation) ----
11933
- p_release = sub.add_parser(
11934
- "release",
11935
- help="Stamp CHANGELOG, cut SemVer tag, mirror, and create GitHub Release",
11936
- formatter_class=CLIHelpFormatter,
11937
- description=textwrap.dedent(
11938
- """\
11939
- Issue #24 release automation. Four idempotent phases:
11940
- 1. Stamp CHANGELOG.md (move [Unreleased] entries under [X.Y.Z]).
11941
- 2. Annotated tag vX.Y.Z + push --follow-tags.
11942
- 3. Mirror push (replay private → push public branch + tag).
11943
- 4. GitHub Release create on the public mirror.
11944
-
11945
- See docs/commands/release.md for full reference.
11946
- """
11947
- ),
11948
- )
11949
- p_release.add_argument(
11950
- "kind",
11951
- nargs="?",
11952
- choices=["patch", "minor", "major", "prerelease", "finalize"],
11953
- help="Bump kind (omit only with --resume)",
11954
- )
11955
- p_release.add_argument(
11956
- "--resume",
11957
- action="store_true",
11958
- help="Continue an in-progress release (infers vX.Y.Z from latest CHANGELOG header)",
11959
- )
11960
- p_release.add_argument(
11961
- "--dry-run",
11962
- action="store_true",
11963
- help="Print phase plan with diff/tag/release; mutate nothing; exit 0",
11964
- )
11965
- p_release.add_argument(
11966
- "--no-publish",
11967
- action="store_true",
11968
- help="Stop after Phase 2 (tag); skip Phases 3-6 (mirror, gh release, npm, brew)",
11969
- )
11970
- p_release.add_argument(
11971
- "--prerelease-id",
11972
- default="rc",
11973
- help="Override 'rc' for prerelease bumps (default 'rc')",
11974
- )
11975
- p_release.add_argument(
11976
- "--bump",
11977
- choices=["patch", "minor", "major"],
11978
- help="REQUIRED with `prerelease` when current is stable; REFUSED when current is prerelease",
11979
- )
11980
- p_release.add_argument(
11981
- "--remote",
11982
- default="origin",
11983
- help="Default 'origin' (private remote)",
11984
- )
11985
- p_release.add_argument(
11986
- "--allow-branch",
11987
- default=None,
11988
- help="Override the on-main refusal (escape hatch)",
11989
- )
11990
- p_release.add_argument(
11991
- "--public-clone",
11992
- default=None,
11993
- help="Override public-clone discovery (default: git config 'release.publicClone' or marker file)",
11994
- )
11995
- p_release.add_argument(
11996
- "--skip-npm",
11997
- action="store_true",
11998
- help="Skip Phase 5 (npm publish). Idempotent: re-running --resume without it picks the channel back up.",
11999
- )
12000
- p_release.add_argument(
12001
- "--brew-clone",
12002
- default=None,
12003
- help="Override brew tap discovery (default: git config 'release.brewClone' or marker file)",
12004
- )
12005
- p_release.add_argument(
12006
- "--skip-brew",
12007
- action="store_true",
12008
- help="Skip Phase 6 (brew formula bump). Idempotent: re-running --resume without it picks the channel back up.",
12009
- )
12010
- p_release.add_argument(
12011
- "--allow-formula-downgrade",
12012
- action="store_true",
12013
- help="Override Phase 6's monotonic-version gate (issue #30). Use only for intentional yank/revert cases — Phase 6 normally refuses to write a formula whose URL pins a lower SemVer than the on-disk formula.",
12014
- )
12015
- p_release.set_defaults(func=cmd_release)
10583
+ # `release` is its own standalone entry-point (bin/cctally-release);
10584
+ # no `release` subparser is registered on the main `cctally` CLI.
10585
+ # See docs/RELEASE.md.
12016
10586
 
12017
10587
  # ---- hook-tick (internal — hidden from --help, see onboarding spec §3) ----
12018
10588
  ht = sub.add_parser(
@@ -12359,12 +10929,13 @@ def _share_history_recipe_id() -> str:
12359
10929
 
12360
10930
 
12361
10931
  def _share_resolve_version() -> str:
12362
- """Source from CHANGELOG via the existing release helper. Empty string if unset.
10932
+ """Source from CHANGELOG via the public helper. Empty string if unset.
12363
10933
 
12364
- `_release_read_latest_release_version` returns `(version, date) | None`;
12365
- the snapshot's `version` field carries the version string only.
10934
+ `_lib_changelog._read_latest_changelog_version` returns
10935
+ `(version, date) | None`; the snapshot's `version` field carries
10936
+ the version string only.
12366
10937
  """
12367
- info = _release_read_latest_release_version()
10938
+ info = _lib_changelog._read_latest_changelog_version()
12368
10939
  return info[0] if info else ""
12369
10940
 
12370
10941
 
@@ -13879,6 +12450,7 @@ _tui_build_trend = _cctally_tui._tui_build_trend
13879
12450
  _tui_build_weekly_history = _cctally_tui._tui_build_weekly_history
13880
12451
  _tui_build_sessions = _cctally_tui._tui_build_sessions
13881
12452
  _tui_build_session_detail = _cctally_tui._tui_build_session_detail
12453
+ _tui_build_session_detail_indexed = _cctally_tui._tui_build_session_detail_indexed
13882
12454
  _tui_build_snapshot = _cctally_tui._tui_build_snapshot
13883
12455
  _tui_empty_snapshot = _cctally_tui._tui_empty_snapshot
13884
12456
  # Key reader + dispatcher + sync thread base class
@@ -13928,8 +12500,6 @@ cmd_tui = _cctally_tui.cmd_tui
13928
12500
  _tui_render_once = _cctally_tui._tui_render_once
13929
12501
 
13930
12502
 
13931
-
13932
-
13933
12503
  def main(argv: list[str] | None = None) -> int:
13934
12504
  _migrate_legacy_data_dir()
13935
12505
  parser = build_parser()
@@ -13938,7 +12508,7 @@ def main(argv: list[str] | None = None) -> int:
13938
12508
  # release header (issue #24); handle BEFORE subcommand dispatch so it
13939
12509
  # works without a subcommand (`cctally --version`).
13940
12510
  if getattr(args, "version", False):
13941
- v = _release_read_latest_release_version()
12511
+ v = _lib_changelog._read_latest_changelog_version()
13942
12512
  if v is None:
13943
12513
  print("cctally unknown")
13944
12514
  else: