cctally 1.8.2 → 1.9.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/CHANGELOG.md +9 -0
- package/bin/_cctally_setup.py +248 -1
- package/bin/_cctally_update.py +29 -5
- package/bin/_lib_changelog.py +44 -0
- package/bin/_lib_semver.py +1 -1
- package/bin/_lib_share_templates.py +4 -2
- package/bin/cctally +35 -1474
- package/package.json +2 -3
- package/bin/_cctally_release.py +0 -751
- package/bin/cctally-release +0 -3
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
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
#
|
|
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 —
|
|
404
|
-
#
|
|
405
|
-
#
|
|
406
|
-
#
|
|
407
|
-
#
|
|
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
|
|
@@ -803,73 +790,6 @@ DashboardHTTPHandler = _cctally_dashboard.DashboardHTTPHandler
|
|
|
803
790
|
cmd_dashboard = _cctally_dashboard.cmd_dashboard
|
|
804
791
|
|
|
805
792
|
|
|
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
793
|
RELEASE_HEADER_RE = re.compile(
|
|
874
794
|
rf'^## \[({_SEMVER_NUM}\.{_SEMVER_NUM}\.{_SEMVER_NUM}'
|
|
875
795
|
rf'(?:-[a-zA-Z][a-zA-Z0-9-]*\.{_SEMVER_NUM})?)\] - (\d{{4}}-\d{{2}}-\d{{2}})\s*$',
|
|
@@ -879,1284 +799,11 @@ RELEASE_HEADER_RE = re.compile(
|
|
|
879
799
|
RELEASE_SUBSECTION_ORDER = ("Added", "Changed", "Deprecated", "Removed", "Fixed", "Security")
|
|
880
800
|
|
|
881
801
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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)
|
|
802
|
+
# `cmd_release` is NOT defined here: the release subcommand was extracted
|
|
803
|
+
# from the main `cctally` CLI and lives as a standalone entry point
|
|
804
|
+
# (bin/cctally-release). The implementation is in bin/_cctally_release.py.
|
|
805
|
+
# Tests that exercise `cmd_release` import it directly from
|
|
806
|
+
# `_cctally_release`.
|
|
2160
807
|
|
|
2161
808
|
|
|
2162
809
|
# Files to scan when detecting the legacy status-line snippet (Section 5).
|
|
@@ -2331,7 +978,6 @@ def _decode_escaped_cwd(dir_name: str) -> str:
|
|
|
2331
978
|
return "/" + stripped.replace("-", "/")
|
|
2332
979
|
|
|
2333
980
|
|
|
2334
|
-
|
|
2335
981
|
def _sum_cost_for_range(
|
|
2336
982
|
start: dt.datetime,
|
|
2337
983
|
end: dt.datetime,
|
|
@@ -2414,7 +1060,6 @@ def _resolve_primary_model_for_block(
|
|
|
2414
1060
|
return row["model"]
|
|
2415
1061
|
|
|
2416
1062
|
|
|
2417
|
-
|
|
2418
1063
|
def _read_keychain_oauth_blob() -> str | None:
|
|
2419
1064
|
"""Read the Claude Code keychain entry on macOS via `security`.
|
|
2420
1065
|
|
|
@@ -5276,7 +3921,6 @@ def _parse_cli_date_range(
|
|
|
5276
3921
|
return range_start, range_end
|
|
5277
3922
|
|
|
5278
3923
|
|
|
5279
|
-
|
|
5280
3924
|
def cmd_daily(args: argparse.Namespace) -> int:
|
|
5281
3925
|
"""Show usage report grouped by display-timezone date."""
|
|
5282
3926
|
_share_validate_args(args)
|
|
@@ -6475,7 +5119,6 @@ _diff_render_json = _lib_diff_kernel._diff_render_json
|
|
|
6475
5119
|
_diff_resolve_anchor = _lib_diff_kernel._diff_resolve_anchor
|
|
6476
5120
|
|
|
6477
5121
|
|
|
6478
|
-
|
|
6479
5122
|
def cmd_diff(args: argparse.Namespace) -> int:
|
|
6480
5123
|
"""Compare Claude usage between two windows."""
|
|
6481
5124
|
now_utc = _command_as_of()
|
|
@@ -10209,7 +8852,7 @@ def doctor_gather_state(
|
|
|
10209
8852
|
)
|
|
10210
8853
|
|
|
10211
8854
|
# ── Meta ─────────────────────────────────────────────────────────
|
|
10212
|
-
cctally_version_tuple =
|
|
8855
|
+
cctally_version_tuple = _lib_changelog._read_latest_changelog_version()
|
|
10213
8856
|
cctally_version = (
|
|
10214
8857
|
cctally_version_tuple[0] if cctally_version_tuple else "unknown"
|
|
10215
8858
|
)
|
|
@@ -11929,90 +10572,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
11929
10572
|
)
|
|
11930
10573
|
doctor_p.set_defaults(func=cmd_doctor)
|
|
11931
10574
|
|
|
11932
|
-
#
|
|
11933
|
-
|
|
11934
|
-
|
|
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)
|
|
10575
|
+
# `release` is its own standalone entry-point (bin/cctally-release);
|
|
10576
|
+
# no `release` subparser is registered on the main `cctally` CLI.
|
|
10577
|
+
# See docs/RELEASE.md.
|
|
12016
10578
|
|
|
12017
10579
|
# ---- hook-tick (internal — hidden from --help, see onboarding spec §3) ----
|
|
12018
10580
|
ht = sub.add_parser(
|
|
@@ -12359,12 +10921,13 @@ def _share_history_recipe_id() -> str:
|
|
|
12359
10921
|
|
|
12360
10922
|
|
|
12361
10923
|
def _share_resolve_version() -> str:
|
|
12362
|
-
"""Source from CHANGELOG via the
|
|
10924
|
+
"""Source from CHANGELOG via the public helper. Empty string if unset.
|
|
12363
10925
|
|
|
12364
|
-
`
|
|
12365
|
-
the snapshot's `version` field carries
|
|
10926
|
+
`_lib_changelog._read_latest_changelog_version` returns
|
|
10927
|
+
`(version, date) | None`; the snapshot's `version` field carries
|
|
10928
|
+
the version string only.
|
|
12366
10929
|
"""
|
|
12367
|
-
info =
|
|
10930
|
+
info = _lib_changelog._read_latest_changelog_version()
|
|
12368
10931
|
return info[0] if info else ""
|
|
12369
10932
|
|
|
12370
10933
|
|
|
@@ -13928,8 +12491,6 @@ cmd_tui = _cctally_tui.cmd_tui
|
|
|
13928
12491
|
_tui_render_once = _cctally_tui._tui_render_once
|
|
13929
12492
|
|
|
13930
12493
|
|
|
13931
|
-
|
|
13932
|
-
|
|
13933
12494
|
def main(argv: list[str] | None = None) -> int:
|
|
13934
12495
|
_migrate_legacy_data_dir()
|
|
13935
12496
|
parser = build_parser()
|
|
@@ -13938,7 +12499,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
13938
12499
|
# release header (issue #24); handle BEFORE subcommand dispatch so it
|
|
13939
12500
|
# works without a subcommand (`cctally --version`).
|
|
13940
12501
|
if getattr(args, "version", False):
|
|
13941
|
-
v =
|
|
12502
|
+
v = _lib_changelog._read_latest_changelog_version()
|
|
13942
12503
|
if v is None:
|
|
13943
12504
|
print("cctally unknown")
|
|
13944
12505
|
else:
|