cctally 1.8.1 → 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 +20 -0
- package/bin/_cctally_dashboard.py +158 -98
- package/bin/_cctally_setup.py +248 -1
- package/bin/_cctally_tui.py +156 -31
- 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/_lib_view_models.py +784 -0
- package/bin/cctally +153 -1508
- package/dashboard/static/assets/{index-CfXu9Fx_.js → index-cWE5HB8O.js} +2 -2
- package/dashboard/static/dashboard.html +1 -1
- 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
|
|
@@ -359,6 +348,19 @@ TrendView = _lib_view_models.TrendView
|
|
|
359
348
|
build_trend_view = _lib_view_models.build_trend_view
|
|
360
349
|
SessionsView = _lib_view_models.SessionsView
|
|
361
350
|
build_sessions_view = _lib_view_models.build_sessions_view
|
|
351
|
+
BlocksView = _lib_view_models.BlocksView
|
|
352
|
+
build_blocks_view = _lib_view_models.build_blocks_view
|
|
353
|
+
build_blocks_view_from_table_rows = _lib_view_models.build_blocks_view_from_table_rows
|
|
354
|
+
ForecastView = _lib_view_models.ForecastView
|
|
355
|
+
build_forecast_view = _lib_view_models.build_forecast_view
|
|
356
|
+
CodexDailyView = _lib_view_models.CodexDailyView
|
|
357
|
+
build_codex_daily_view = _lib_view_models.build_codex_daily_view
|
|
358
|
+
CodexMonthlyView = _lib_view_models.CodexMonthlyView
|
|
359
|
+
build_codex_monthly_view = _lib_view_models.build_codex_monthly_view
|
|
360
|
+
CodexWeeklyView = _lib_view_models.CodexWeeklyView
|
|
361
|
+
build_codex_weekly_view = _lib_view_models.build_codex_weekly_view
|
|
362
|
+
CodexSessionView = _lib_view_models.CodexSessionView
|
|
363
|
+
build_codex_session_view = _lib_view_models.build_codex_session_view
|
|
362
364
|
|
|
363
365
|
_lib_render = _load_sibling("_lib_render")
|
|
364
366
|
_CODEX_MONTHS = _lib_render._CODEX_MONTHS
|
|
@@ -387,13 +389,11 @@ _render_five_hour_blocks_table = _lib_render._render_five_hour_blocks_table
|
|
|
387
389
|
|
|
388
390
|
# Eager re-export of bin/_cctally_setup.py (lazy I/O sibling that loads
|
|
389
391
|
# at startup to keep `ns["cmd_setup"](...)` / `ns["_setup_X"](...)`
|
|
390
|
-
# direct-dict test patterns working —
|
|
391
|
-
#
|
|
392
|
-
#
|
|
393
|
-
#
|
|
394
|
-
#
|
|
395
|
-
# tests bypass PEP 562. Same pattern as Phase A `_lib_*` modules; no
|
|
396
|
-
# 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.
|
|
397
397
|
_cctally_setup = _load_sibling("_cctally_setup")
|
|
398
398
|
_settings_merge_install = _cctally_setup._settings_merge_install
|
|
399
399
|
_settings_merge_uninstall = _cctally_setup._settings_merge_uninstall
|
|
@@ -777,6 +777,7 @@ _dashboard_build_monthly_periods = _cctally_dashboard._dashboard_build_monthly_p
|
|
|
777
777
|
_dashboard_build_weekly_periods = _cctally_dashboard._dashboard_build_weekly_periods
|
|
778
778
|
_build_block_detail = _cctally_dashboard._build_block_detail
|
|
779
779
|
_dashboard_build_blocks_panel = _cctally_dashboard._dashboard_build_blocks_panel
|
|
780
|
+
_dashboard_build_blocks_view = _cctally_dashboard._dashboard_build_blocks_view
|
|
780
781
|
_dashboard_build_daily_panel = _cctally_dashboard._dashboard_build_daily_panel
|
|
781
782
|
_empty_dashboard_snapshot = _cctally_dashboard._empty_dashboard_snapshot
|
|
782
783
|
_iso_z = _cctally_dashboard._iso_z
|
|
@@ -789,73 +790,6 @@ DashboardHTTPHandler = _cctally_dashboard.DashboardHTTPHandler
|
|
|
789
790
|
cmd_dashboard = _cctally_dashboard.cmd_dashboard
|
|
790
791
|
|
|
791
792
|
|
|
792
|
-
# === Lazy-loaded library siblings (PEP 562 registry) ===
|
|
793
|
-
#
|
|
794
|
-
# Tests reach into cctally's namespace for private symbols via
|
|
795
|
-
# SourceFileLoader (e.g. cctally._release_phase_stamp_done). When those
|
|
796
|
-
# symbols live in lazy I/O modules (_cctally_*.py), Python's
|
|
797
|
-
# module-level __getattr__ resolves them on first access by importing
|
|
798
|
-
# the owning module and splicing its whole symbol surface into
|
|
799
|
-
# bin/cctally's globals. Subsequent accesses skip __getattr__ entirely.
|
|
800
|
-
#
|
|
801
|
-
# Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §4
|
|
802
|
-
|
|
803
|
-
_LAZY_MODULES: dict[str, list[str]] = {
|
|
804
|
-
"_cctally_release": [
|
|
805
|
-
"_FORMULA_VERSION_RE",
|
|
806
|
-
"_RELEASE_NPM_POLL_INTERVAL_S_DEFAULT",
|
|
807
|
-
"_RELEASE_NPM_POLL_TIMEOUT_S_DEFAULT",
|
|
808
|
-
"_homebrew_template_path",
|
|
809
|
-
"_package_json_path",
|
|
810
|
-
"_release_brew_archive_url",
|
|
811
|
-
"_release_canonical_body",
|
|
812
|
-
"_release_compute_brew_sha256",
|
|
813
|
-
"_release_dry_run",
|
|
814
|
-
"_release_extract_formula_version",
|
|
815
|
-
"_release_npm_poll_timing",
|
|
816
|
-
"_release_parse_changelog",
|
|
817
|
-
"_release_preflight_tag_clobber",
|
|
818
|
-
"_release_print_gh_fallback",
|
|
819
|
-
"_release_stamp_changelog",
|
|
820
|
-
"_release_stamp_package_json",
|
|
821
|
-
"cmd_release",
|
|
822
|
-
],
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
# Inverted lookup: symbol -> module name. Built at module load with a
|
|
826
|
-
# collision assertion so two modules can't accidentally claim the same name.
|
|
827
|
-
_LAZY_NAME_TO_MODULE: dict[str, str] = {}
|
|
828
|
-
for _mod_name, _names in _LAZY_MODULES.items():
|
|
829
|
-
for _n in _names:
|
|
830
|
-
assert _n not in _LAZY_NAME_TO_MODULE, (
|
|
831
|
-
f"PEP 562 registry collision: {_n!r} claimed by both "
|
|
832
|
-
f"{_LAZY_NAME_TO_MODULE.get(_n)!r} and {_mod_name!r}"
|
|
833
|
-
)
|
|
834
|
-
_LAZY_NAME_TO_MODULE[_n] = _mod_name
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
def __getattr__(name: str): # PEP 562
|
|
838
|
-
"""Lazy-load a registered sibling module on first attribute access.
|
|
839
|
-
|
|
840
|
-
Tests do `cctally._release_phase_stamp_done(...)` and similar; this
|
|
841
|
-
function fires, imports the owning module via _load_sibling, splices
|
|
842
|
-
every symbol the registry claims for that module into globals(), and
|
|
843
|
-
returns the requested attribute. Subsequent attribute accesses on
|
|
844
|
-
cctally.X find the symbol directly in globals — no __getattr__ trip.
|
|
845
|
-
|
|
846
|
-
Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §4.2
|
|
847
|
-
"""
|
|
848
|
-
mod_name = _LAZY_NAME_TO_MODULE.get(name)
|
|
849
|
-
if mod_name is None:
|
|
850
|
-
raise AttributeError(
|
|
851
|
-
f"module {__name__!r} has no attribute {name!r}"
|
|
852
|
-
)
|
|
853
|
-
mod = _load_sibling(mod_name)
|
|
854
|
-
for n in _LAZY_MODULES[mod_name]:
|
|
855
|
-
globals()[n] = getattr(mod, n)
|
|
856
|
-
return globals()[name]
|
|
857
|
-
|
|
858
|
-
|
|
859
793
|
RELEASE_HEADER_RE = re.compile(
|
|
860
794
|
rf'^## \[({_SEMVER_NUM}\.{_SEMVER_NUM}\.{_SEMVER_NUM}'
|
|
861
795
|
rf'(?:-[a-zA-Z][a-zA-Z0-9-]*\.{_SEMVER_NUM})?)\] - (\d{{4}}-\d{{2}}-\d{{2}})\s*$',
|
|
@@ -865,1284 +799,11 @@ RELEASE_HEADER_RE = re.compile(
|
|
|
865
799
|
RELEASE_SUBSECTION_ORDER = ("Added", "Changed", "Deprecated", "Removed", "Fixed", "Security")
|
|
866
800
|
|
|
867
801
|
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
return None
|
|
874
|
-
m = RELEASE_HEADER_RE.search(text)
|
|
875
|
-
if not m:
|
|
876
|
-
return None
|
|
877
|
-
return (m.group(1), m.group(2))
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
def _release_preflight_branch(allow_branch: str | None) -> None:
|
|
881
|
-
"""Refuse unless on main or --allow-branch matches.
|
|
882
|
-
|
|
883
|
-
Spec §10.1 step 2: branch check fires for both real runs and --dry-run
|
|
884
|
-
(a dry-run on the wrong branch would mislead).
|
|
885
|
-
"""
|
|
886
|
-
branch = subprocess.check_output(
|
|
887
|
-
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
888
|
-
text=True,
|
|
889
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
890
|
-
).strip()
|
|
891
|
-
expected = allow_branch if allow_branch else "main"
|
|
892
|
-
if branch != expected:
|
|
893
|
-
if allow_branch:
|
|
894
|
-
print(
|
|
895
|
-
f"release: refusing to cut from {branch}; allow-branch was {allow_branch}",
|
|
896
|
-
file=sys.stderr,
|
|
897
|
-
)
|
|
898
|
-
else:
|
|
899
|
-
print(
|
|
900
|
-
f"release: refusing to cut from {branch}; "
|
|
901
|
-
f"use --allow-branch {branch} if intentional",
|
|
902
|
-
file=sys.stderr,
|
|
903
|
-
)
|
|
904
|
-
sys.exit(2)
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
def _release_preflight_clean_tree() -> None:
|
|
908
|
-
"""Refuse if working tree is dirty (spec §10.1 step 3)."""
|
|
909
|
-
out = subprocess.check_output(
|
|
910
|
-
["git", "status", "--porcelain"],
|
|
911
|
-
text=True,
|
|
912
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
913
|
-
)
|
|
914
|
-
if out.strip():
|
|
915
|
-
print("release: working tree dirty; commit or stash first", file=sys.stderr)
|
|
916
|
-
sys.exit(2)
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
def _release_preflight_up_to_date(remote: str) -> None:
|
|
920
|
-
"""Refuse if local branch is behind <remote>/<branch>; local-ahead OK.
|
|
921
|
-
|
|
922
|
-
Spec §10.1 step 4. Network failure is non-fatal — operator can re-run
|
|
923
|
-
with --resume after fixing connectivity.
|
|
924
|
-
"""
|
|
925
|
-
branch = subprocess.check_output(
|
|
926
|
-
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
927
|
-
text=True,
|
|
928
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
929
|
-
).strip()
|
|
930
|
-
try:
|
|
931
|
-
subprocess.check_call(
|
|
932
|
-
["git", "fetch", "--quiet", remote, branch],
|
|
933
|
-
stdout=subprocess.DEVNULL,
|
|
934
|
-
stderr=subprocess.DEVNULL,
|
|
935
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
936
|
-
)
|
|
937
|
-
except subprocess.CalledProcessError:
|
|
938
|
-
# Network failure — proceed; operator can re-run with --resume.
|
|
939
|
-
return
|
|
940
|
-
local = subprocess.check_output(
|
|
941
|
-
["git", "rev-parse", branch],
|
|
942
|
-
text=True,
|
|
943
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
944
|
-
).strip()
|
|
945
|
-
remote_sha = subprocess.check_output(
|
|
946
|
-
["git", "rev-parse", f"{remote}/{branch}"],
|
|
947
|
-
text=True,
|
|
948
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
949
|
-
).strip()
|
|
950
|
-
if local == remote_sha:
|
|
951
|
-
return
|
|
952
|
-
base = subprocess.check_output(
|
|
953
|
-
["git", "merge-base", local, remote_sha],
|
|
954
|
-
text=True,
|
|
955
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
956
|
-
).strip()
|
|
957
|
-
if base == remote_sha:
|
|
958
|
-
return # local is strictly ahead — fine.
|
|
959
|
-
print(
|
|
960
|
-
f"release: local {branch} is behind {remote}/{branch}; pull first",
|
|
961
|
-
file=sys.stderr,
|
|
962
|
-
)
|
|
963
|
-
sys.exit(2)
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
def _release_phase_stamp_done(version: str) -> tuple[bool, str | None]:
|
|
967
|
-
"""Phase-1 read-only signal: returns (True, head_sha) when CHANGELOG.md
|
|
968
|
-
on disk AND HEAD's tree both contain a `## [version] - YYYY-MM-DD` header
|
|
969
|
-
line AND HEAD's commit subject is exactly `chore(release): vX.Y.Z`.
|
|
970
|
-
|
|
971
|
-
Date is read from the existing CHANGELOG, NOT recomputed from "today" —
|
|
972
|
-
`--resume` after UTC midnight rollover must still detect a stamp written
|
|
973
|
-
yesterday. Returns (False, None) on any miss (file gone, header absent,
|
|
974
|
-
HEAD blob lacks the header). When the header IS present on HEAD but
|
|
975
|
-
HEAD's subject is not the stamp subject — meaning an unrelated commit
|
|
976
|
-
landed on top of the stamp — exits 2 with a diagnostic rather than
|
|
977
|
-
tagging the wrong SHA. Read-only — never mutates.
|
|
978
|
-
"""
|
|
979
|
-
expected_prefix = f"## [{version}] - "
|
|
980
|
-
try:
|
|
981
|
-
text = CHANGELOG_PATH.read_text(encoding="utf-8")
|
|
982
|
-
except FileNotFoundError:
|
|
983
|
-
return (False, None)
|
|
984
|
-
if not any(line.startswith(expected_prefix) for line in text.splitlines()):
|
|
985
|
-
return (False, None)
|
|
986
|
-
head_sha = subprocess.check_output(
|
|
987
|
-
["git", "rev-parse", "HEAD"],
|
|
988
|
-
text=True,
|
|
989
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
990
|
-
).strip()
|
|
991
|
-
try:
|
|
992
|
-
blob = subprocess.check_output(
|
|
993
|
-
["git", "show", "HEAD:CHANGELOG.md"],
|
|
994
|
-
text=True,
|
|
995
|
-
stderr=subprocess.DEVNULL,
|
|
996
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
997
|
-
)
|
|
998
|
-
except subprocess.CalledProcessError:
|
|
999
|
-
return (False, None)
|
|
1000
|
-
if not any(line.startswith(expected_prefix) for line in blob.splitlines()):
|
|
1001
|
-
return (False, None)
|
|
1002
|
-
# HEAD's blob carries the stamp; verify HEAD itself IS the stamp commit.
|
|
1003
|
-
# An unrelated commit landed on top of the stamp would still satisfy the
|
|
1004
|
-
# blob check (CHANGELOG.md unchanged on top), but tagging that SHA would
|
|
1005
|
-
# mis-tag the release. Refuse with a clear diagnostic instead.
|
|
1006
|
-
head_subject = subprocess.check_output(
|
|
1007
|
-
["git", "log", "-1", "--format=%s", "HEAD"],
|
|
1008
|
-
text=True,
|
|
1009
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
1010
|
-
).strip()
|
|
1011
|
-
expected_subject = f"chore(release): v{version}"
|
|
1012
|
-
if head_subject != expected_subject:
|
|
1013
|
-
print(
|
|
1014
|
-
f"release: HEAD is not the stamp commit (subject: {head_subject!r}); "
|
|
1015
|
-
f"--resume cannot continue. Either checkout the stamp commit or "
|
|
1016
|
-
f"revert the on-top commits.",
|
|
1017
|
-
file=sys.stderr,
|
|
1018
|
-
)
|
|
1019
|
-
sys.exit(2)
|
|
1020
|
-
# Belt-and-suspenders: package.json's version must also be stamped
|
|
1021
|
-
# (Phase 1 co-stamp invariant). When package.json doesn't exist
|
|
1022
|
-
# (legacy fixtures, source clones pre-Task-1), skip silently —
|
|
1023
|
-
# the CHANGELOG header check above is still authoritative.
|
|
1024
|
-
pj_path = cctally._package_json_path()
|
|
1025
|
-
if pj_path.exists():
|
|
1026
|
-
try:
|
|
1027
|
-
pj = json.loads(pj_path.read_text(encoding="utf-8"))
|
|
1028
|
-
except json.JSONDecodeError:
|
|
1029
|
-
return (False, None)
|
|
1030
|
-
if pj.get("version") != version:
|
|
1031
|
-
return (False, None)
|
|
1032
|
-
return (True, head_sha)
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
def _release_build_stamp_message(
|
|
1036
|
-
version: str,
|
|
1037
|
-
body: str,
|
|
1038
|
-
invocation: str,
|
|
1039
|
-
prior_head: str,
|
|
1040
|
-
bump_kind: str,
|
|
1041
|
-
subsection_counts: dict[str, int],
|
|
1042
|
-
) -> str:
|
|
1043
|
-
"""Build the full stamp commit message: private body + `--- public ---`
|
|
1044
|
-
block + canonical body (spec §7.1).
|
|
1045
|
-
|
|
1046
|
-
Subject is identical on both surfaces (`chore(release): vX.Y.Z`); the
|
|
1047
|
-
public body is the canonical CHANGELOG section body byte-for-byte.
|
|
1048
|
-
"""
|
|
1049
|
-
counts_str = ", ".join(
|
|
1050
|
-
f"{name} ({n})" for name, n in subsection_counts.items() if n > 0
|
|
1051
|
-
) or "(none)"
|
|
1052
|
-
total = sum(subsection_counts.values())
|
|
1053
|
-
private_body = (
|
|
1054
|
-
f"chore(release): v{version}\n"
|
|
1055
|
-
f"\n"
|
|
1056
|
-
f"Stamp release v{version} over {total} [Unreleased] entries.\n"
|
|
1057
|
-
f"\n"
|
|
1058
|
-
f"Run by `{invocation}` from main at {prior_head[:7]}.\n"
|
|
1059
|
-
f"Bump kind: {bump_kind}.\n"
|
|
1060
|
-
f"Subsections stamped: {counts_str}.\n"
|
|
1061
|
-
)
|
|
1062
|
-
public_block = f"--- public ---\nchore(release): v{version}\n\n{body}\n"
|
|
1063
|
-
return private_body + "\n" + public_block
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
def _release_run_phase_stamp(
|
|
1067
|
-
version: str,
|
|
1068
|
-
remote: str,
|
|
1069
|
-
invocation: str,
|
|
1070
|
-
bump_kind: str = "(unspecified)",
|
|
1071
|
-
) -> str:
|
|
1072
|
-
"""Phase 1 — stamp `[Unreleased]` into a `[X.Y.Z]` section and commit.
|
|
1073
|
-
|
|
1074
|
-
Idempotent: if `_release_phase_stamp_done(version)` reports done,
|
|
1075
|
-
short-circuits with the existing HEAD SHA. Otherwise rewrites
|
|
1076
|
-
CHANGELOG.md atomically (`os.replace`), stages it, verifies the
|
|
1077
|
-
staging cache contains exactly `CHANGELOG.md` (defends against
|
|
1078
|
-
operator-prestaged content slipping into the release commit), then
|
|
1079
|
-
invokes `git commit -F <msgfile> --cleanup=verbatim` so the
|
|
1080
|
-
`### Added` / `### Fixed` headings survive (default cleanup strips
|
|
1081
|
-
`#`-prefixed lines as comments). Returns the new commit SHA.
|
|
1082
|
-
"""
|
|
1083
|
-
done, head_sha = _release_phase_stamp_done(version)
|
|
1084
|
-
if done:
|
|
1085
|
-
print(
|
|
1086
|
-
f"release: stamp ✓ (already done — commit {head_sha[:7]})"
|
|
1087
|
-
)
|
|
1088
|
-
return head_sha
|
|
1089
|
-
|
|
1090
|
-
print(f"release: stamp v{version}")
|
|
1091
|
-
today = (
|
|
1092
|
-
os.environ.get("CCTALLY_RELEASE_DATE_UTC")
|
|
1093
|
-
or dt.datetime.now(dt.timezone.utc).date().isoformat()
|
|
1094
|
-
)
|
|
1095
|
-
old_text = CHANGELOG_PATH.read_text(encoding="utf-8")
|
|
1096
|
-
try:
|
|
1097
|
-
new_text, body = cctally._release_stamp_changelog(old_text, version, today)
|
|
1098
|
-
except ValueError as e:
|
|
1099
|
-
# Mirror the dry-run path's contract: refuse with exit 2 when
|
|
1100
|
-
# `[Unreleased]` is empty (spec §3 step 4 / §11.4 row 9).
|
|
1101
|
-
print(f"release: {e}", file=sys.stderr)
|
|
1102
|
-
sys.exit(2)
|
|
1103
|
-
|
|
1104
|
-
# Subsection counts — read from the OLD parse (before the stamp moved
|
|
1105
|
-
# bullets out of [Unreleased]); private-body diagnostics only.
|
|
1106
|
-
parsed = cctally._release_parse_changelog(old_text)
|
|
1107
|
-
counts: dict[str, int] = {}
|
|
1108
|
-
if parsed["sections"]:
|
|
1109
|
-
for sub in parsed["sections"][0]["subsections"]:
|
|
1110
|
-
if sub["bullets"]:
|
|
1111
|
-
heading = sub["heading"].replace("###", "").strip()
|
|
1112
|
-
counts[heading] = len(sub["bullets"])
|
|
1113
|
-
|
|
1114
|
-
prior_head = subprocess.check_output(
|
|
1115
|
-
["git", "rev-parse", "HEAD"],
|
|
1116
|
-
text=True,
|
|
1117
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
1118
|
-
).strip()
|
|
1119
|
-
|
|
1120
|
-
# Atomic write: write to .tmp.<pid> sibling, then os.replace(). Wrapped
|
|
1121
|
-
# in try/finally so a failure between write_text and os.replace doesn't
|
|
1122
|
-
# leak a stray .tmp.<pid> file next to CHANGELOG.md.
|
|
1123
|
-
tmp = CHANGELOG_PATH.with_suffix(f".md.tmp.{os.getpid()}")
|
|
1124
|
-
try:
|
|
1125
|
-
tmp.write_text(new_text, encoding="utf-8")
|
|
1126
|
-
os.replace(tmp, CHANGELOG_PATH)
|
|
1127
|
-
finally:
|
|
1128
|
-
try:
|
|
1129
|
-
tmp.unlink()
|
|
1130
|
-
except FileNotFoundError:
|
|
1131
|
-
pass
|
|
1132
|
-
|
|
1133
|
-
# Stage CHANGELOG.md only (`cwd` so the relative path resolves).
|
|
1134
|
-
subprocess.check_call(
|
|
1135
|
-
["git", "add", CHANGELOG_PATH.name],
|
|
1136
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
1137
|
-
)
|
|
1138
|
-
|
|
1139
|
-
# Co-stamp package.json (npm channel; Phase 5 reads this version field).
|
|
1140
|
-
# Path resolved via _package_json_path() (which derives from
|
|
1141
|
-
# CHANGELOG_PATH) so tests monkeypatching CHANGELOG_PATH to a temp
|
|
1142
|
-
# repo automatically see the correct (absent) package.json there.
|
|
1143
|
-
# Guarded by `.exists()` so fixture replays for old scenarios
|
|
1144
|
-
# pre-dating the npm channel still pass — they have no package.json
|
|
1145
|
-
# to stamp.
|
|
1146
|
-
pj_path = cctally._package_json_path()
|
|
1147
|
-
if pj_path.exists():
|
|
1148
|
-
pj_old = pj_path.read_text(encoding="utf-8")
|
|
1149
|
-
pj_new = cctally._release_stamp_package_json(pj_old, version)
|
|
1150
|
-
pj_path.write_text(pj_new, encoding="utf-8")
|
|
1151
|
-
subprocess.check_call(
|
|
1152
|
-
["git", "add", pj_path.name],
|
|
1153
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
1154
|
-
)
|
|
1155
|
-
|
|
1156
|
-
# Trailer staging guard: refuse if anything else is staged. Defensive
|
|
1157
|
-
# — preflight should have caught a dirty index, but operators can race
|
|
1158
|
-
# `git add` between preflight and stamp.
|
|
1159
|
-
staged = (
|
|
1160
|
-
subprocess.check_output(
|
|
1161
|
-
["git", "diff", "--cached", "--name-only"],
|
|
1162
|
-
text=True,
|
|
1163
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
1164
|
-
)
|
|
1165
|
-
.strip()
|
|
1166
|
-
.splitlines()
|
|
1167
|
-
)
|
|
1168
|
-
expected_staged = (
|
|
1169
|
-
["CHANGELOG.md", "package.json"]
|
|
1170
|
-
if pj_path.exists()
|
|
1171
|
-
else ["CHANGELOG.md"]
|
|
1172
|
-
)
|
|
1173
|
-
if staged != expected_staged:
|
|
1174
|
-
print(
|
|
1175
|
-
f"release: stamp aborted; expected only {expected_staged} staged, "
|
|
1176
|
-
f"got {staged}",
|
|
1177
|
-
file=sys.stderr,
|
|
1178
|
-
)
|
|
1179
|
-
recover_paths = " ".join(expected_staged)
|
|
1180
|
-
print(
|
|
1181
|
-
"release: to recover, run `git reset HEAD` and "
|
|
1182
|
-
f"`git checkout -- {recover_paths}`",
|
|
1183
|
-
file=sys.stderr,
|
|
1184
|
-
)
|
|
1185
|
-
sys.exit(3)
|
|
1186
|
-
|
|
1187
|
-
msg = _release_build_stamp_message(
|
|
1188
|
-
version, body, invocation, prior_head, bump_kind, counts
|
|
1189
|
-
)
|
|
1190
|
-
msg_file = CHANGELOG_PATH.parent / f".release-msg.{os.getpid()}.txt"
|
|
1191
|
-
msg_file.write_text(msg, encoding="utf-8")
|
|
1192
|
-
try:
|
|
1193
|
-
subprocess.check_call(
|
|
1194
|
-
[
|
|
1195
|
-
"git",
|
|
1196
|
-
"commit",
|
|
1197
|
-
"-F",
|
|
1198
|
-
str(msg_file),
|
|
1199
|
-
"--cleanup=verbatim",
|
|
1200
|
-
],
|
|
1201
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
1202
|
-
)
|
|
1203
|
-
finally:
|
|
1204
|
-
try:
|
|
1205
|
-
msg_file.unlink()
|
|
1206
|
-
except FileNotFoundError:
|
|
1207
|
-
pass
|
|
1208
|
-
|
|
1209
|
-
new_sha = subprocess.check_output(
|
|
1210
|
-
["git", "rev-parse", "HEAD"],
|
|
1211
|
-
text=True,
|
|
1212
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
1213
|
-
).strip()
|
|
1214
|
-
print(f"release: stamp ✓ (commit {new_sha[:7]})")
|
|
1215
|
-
return new_sha
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
def _release_phase_tag_done(version: str, remote: str) -> bool:
|
|
1219
|
-
"""Phase-2 read-only signal: tag exists locally AND on `<remote>`.
|
|
1220
|
-
|
|
1221
|
-
Both are required (spec §5.1): a local-only tag means the push step
|
|
1222
|
-
still has work; a remote-only tag (rare — implies a manual push)
|
|
1223
|
-
is treated as not-done so the local annotation is recreated.
|
|
1224
|
-
Read-only — never mutates.
|
|
1225
|
-
"""
|
|
1226
|
-
tag = f"v{version}"
|
|
1227
|
-
local = (
|
|
1228
|
-
subprocess.check_output(
|
|
1229
|
-
["git", "tag", "-l", tag],
|
|
1230
|
-
text=True,
|
|
1231
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
1232
|
-
)
|
|
1233
|
-
.strip()
|
|
1234
|
-
.splitlines()
|
|
1235
|
-
)
|
|
1236
|
-
if tag not in local:
|
|
1237
|
-
return False
|
|
1238
|
-
try:
|
|
1239
|
-
out = subprocess.check_output(
|
|
1240
|
-
["git", "ls-remote", "--tags", remote, f"refs/tags/{tag}"],
|
|
1241
|
-
text=True,
|
|
1242
|
-
stderr=subprocess.DEVNULL,
|
|
1243
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
1244
|
-
).strip()
|
|
1245
|
-
return bool(out)
|
|
1246
|
-
except subprocess.CalledProcessError:
|
|
1247
|
-
return False
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
def _release_run_phase_tag(version: str, remote: str, stamp_sha: str) -> None:
|
|
1251
|
-
"""Phase 2 — write annotated tag `vX.Y.Z` and push commit + tag.
|
|
1252
|
-
|
|
1253
|
-
`stamp_sha` is the SHA returned by Phase 1; tagging is pinned to that
|
|
1254
|
-
SHA rather than re-reading HEAD, so an unrelated commit landing on top
|
|
1255
|
-
between phases never causes a mis-tag (defense in depth — the done-
|
|
1256
|
-
signal in `_release_phase_stamp_done` already refuses that scenario).
|
|
1257
|
-
|
|
1258
|
-
Idempotent: short-circuits when `_release_phase_tag_done`. The
|
|
1259
|
-
body is re-parsed from CHANGELOG.md and run through
|
|
1260
|
-
`_release_canonical_body` so the annotation is byte-identical to
|
|
1261
|
-
Phase 1's commit-message public block (body-canonical-three-sources
|
|
1262
|
-
invariant, spec §7.4 — same string flows into Phase 4's GH Release
|
|
1263
|
-
notes).
|
|
1264
|
-
|
|
1265
|
-
Resume scenarios:
|
|
1266
|
-
- Local tag missing, remote tag missing: create + push as normal.
|
|
1267
|
-
- Local tag exists, remote tag missing (push failed last run, or
|
|
1268
|
-
operator pushed `main` manually): skip `git tag` (would conflict
|
|
1269
|
-
with the existing local tag) and push only the tag explicitly.
|
|
1270
|
-
- Both present: short-circuited by `_release_phase_tag_done` above.
|
|
1271
|
-
|
|
1272
|
-
Signing: use `-s` only when both `user.signingkey` is set AND
|
|
1273
|
-
`tag.gpgsign` is `true`; otherwise `-a` (annotated, unsigned).
|
|
1274
|
-
Operators that have signing keys configured but not enabled for
|
|
1275
|
-
tags get the unsigned path. Signed tags will have a PGP signature
|
|
1276
|
-
block appended to the tag object — the eventual harness scenario
|
|
1277
|
-
must strip lines from `-----BEGIN PGP SIGNATURE-----` onward
|
|
1278
|
-
before comparing the annotation against the canonical body.
|
|
1279
|
-
|
|
1280
|
-
`--cleanup=verbatim` is required: default cleanup strips
|
|
1281
|
-
`#`-prefixed lines, eating every `### Added` / `### Fixed`
|
|
1282
|
-
heading. Push uses `--follow-tags` to ship commit + tag in one
|
|
1283
|
-
operation, plus an explicit `refs/tags/...:refs/tags/...` push as
|
|
1284
|
-
belt-and-suspenders: `--follow-tags` skips tags whose target commit
|
|
1285
|
-
is already on the remote (the resume-after-manual-push case), so
|
|
1286
|
-
the explicit push is what guarantees the tag actually lands.
|
|
1287
|
-
"""
|
|
1288
|
-
if _release_phase_tag_done(version, remote):
|
|
1289
|
-
print(
|
|
1290
|
-
f"release: tag ✓ (already done — v{version} on {remote})"
|
|
1291
|
-
)
|
|
1292
|
-
return
|
|
1293
|
-
|
|
1294
|
-
print(f"release: tag v{version}")
|
|
1295
|
-
tag = f"v{version}"
|
|
1296
|
-
|
|
1297
|
-
# Resume-aware: if the local tag already exists from a prior run, skip
|
|
1298
|
-
# `git tag` (would fail with "tag already exists") and push only.
|
|
1299
|
-
local_tags = (
|
|
1300
|
-
subprocess.check_output(
|
|
1301
|
-
["git", "tag", "-l", tag],
|
|
1302
|
-
text=True,
|
|
1303
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
1304
|
-
)
|
|
1305
|
-
.strip()
|
|
1306
|
-
.splitlines()
|
|
1307
|
-
)
|
|
1308
|
-
if tag in local_tags:
|
|
1309
|
-
print(f"release: tag {tag} exists locally; pushing tag only")
|
|
1310
|
-
else:
|
|
1311
|
-
# Body — re-parse CHANGELOG so the annotation reuses the canonical
|
|
1312
|
-
# body string (matches the public block of Phase 1's commit byte
|
|
1313
|
-
# for byte; same string flows into Phase 4's GH Release notes).
|
|
1314
|
-
text = CHANGELOG_PATH.read_text(encoding="utf-8")
|
|
1315
|
-
parsed = cctally._release_parse_changelog(text)
|
|
1316
|
-
target_section = next(
|
|
1317
|
-
(
|
|
1318
|
-
s
|
|
1319
|
-
for s in parsed["sections"]
|
|
1320
|
-
if s["heading"].lstrip().startswith(f"## [{version}]")
|
|
1321
|
-
),
|
|
1322
|
-
None,
|
|
1323
|
-
)
|
|
1324
|
-
if target_section is None:
|
|
1325
|
-
print(
|
|
1326
|
-
f"release: cannot find [{version}] section in CHANGELOG.md",
|
|
1327
|
-
file=sys.stderr,
|
|
1328
|
-
)
|
|
1329
|
-
sys.exit(3)
|
|
1330
|
-
body = cctally._release_canonical_body(target_section)
|
|
1331
|
-
|
|
1332
|
-
annotation = f"{tag}\n\n{body}\n"
|
|
1333
|
-
msg_file = CHANGELOG_PATH.parent / f".release-tag-msg.{os.getpid()}.txt"
|
|
1334
|
-
try:
|
|
1335
|
-
msg_file.write_text(annotation, encoding="utf-8")
|
|
1336
|
-
signing_key = subprocess.run(
|
|
1337
|
-
["git", "config", "--get", "user.signingkey"],
|
|
1338
|
-
capture_output=True,
|
|
1339
|
-
text=True,
|
|
1340
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
1341
|
-
).stdout.strip()
|
|
1342
|
-
tag_gpgsign = (
|
|
1343
|
-
subprocess.run(
|
|
1344
|
-
["git", "config", "--get", "tag.gpgsign"],
|
|
1345
|
-
capture_output=True,
|
|
1346
|
-
text=True,
|
|
1347
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
1348
|
-
)
|
|
1349
|
-
.stdout.strip()
|
|
1350
|
-
.lower()
|
|
1351
|
-
== "true"
|
|
1352
|
-
)
|
|
1353
|
-
sign_flag = "-s" if (signing_key and tag_gpgsign) else "-a"
|
|
1354
|
-
subprocess.check_call(
|
|
1355
|
-
[
|
|
1356
|
-
"git",
|
|
1357
|
-
"tag",
|
|
1358
|
-
sign_flag,
|
|
1359
|
-
"-F",
|
|
1360
|
-
str(msg_file),
|
|
1361
|
-
"--cleanup=verbatim",
|
|
1362
|
-
tag,
|
|
1363
|
-
stamp_sha,
|
|
1364
|
-
],
|
|
1365
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
1366
|
-
)
|
|
1367
|
-
finally:
|
|
1368
|
-
try:
|
|
1369
|
-
msg_file.unlink()
|
|
1370
|
-
except FileNotFoundError:
|
|
1371
|
-
pass
|
|
1372
|
-
|
|
1373
|
-
branch = subprocess.check_output(
|
|
1374
|
-
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
1375
|
-
text=True,
|
|
1376
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
1377
|
-
).strip()
|
|
1378
|
-
subprocess.check_call(
|
|
1379
|
-
["git", "push", remote, branch, "--follow-tags"],
|
|
1380
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
1381
|
-
)
|
|
1382
|
-
# Belt-and-suspenders: --follow-tags skips tags whose target is already
|
|
1383
|
-
# on the remote (e.g., resume after operator pushed `main` manually).
|
|
1384
|
-
# Explicit refs-spec always pushes the tag; no-op when both are already
|
|
1385
|
-
# on remote (true resume-after-success).
|
|
1386
|
-
subprocess.check_call(
|
|
1387
|
-
["git", "push", remote, f"refs/tags/{tag}:refs/tags/{tag}"],
|
|
1388
|
-
cwd=str(CHANGELOG_PATH.parent),
|
|
1389
|
-
)
|
|
1390
|
-
print(f"release: tag ✓ (annotated, pushed to {remote})")
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
def _release_discover_public_clone(args: argparse.Namespace) -> pathlib.Path:
|
|
1394
|
-
"""Resolve the public-clone path (spec §9.1).
|
|
1395
|
-
|
|
1396
|
-
Priority chain (highest first):
|
|
1397
|
-
1. ``--public-clone <path>`` flag.
|
|
1398
|
-
2. ``git config --get release.publicClone``.
|
|
1399
|
-
3. ``$APP_DIR/release-public-clone-path`` plain-text marker file.
|
|
1400
|
-
|
|
1401
|
-
Each source is silently skipped when missing (unset config key, no
|
|
1402
|
-
marker file, no flag). Refuses with exit 2 only when ALL three
|
|
1403
|
-
sources are absent — operator setup is one-time and explicit, no
|
|
1404
|
-
silent fallback to a hard-coded path.
|
|
1405
|
-
"""
|
|
1406
|
-
candidates: list[tuple[str, pathlib.Path]] = []
|
|
1407
|
-
|
|
1408
|
-
if getattr(args, "public_clone", None):
|
|
1409
|
-
candidates.append(("--public-clone", pathlib.Path(args.public_clone)))
|
|
1410
|
-
|
|
1411
|
-
# Source 2 — git config. `git config --get` exits 1 when the key is
|
|
1412
|
-
# unset; that's the silent-skip path. Other failures (e.g., not in a
|
|
1413
|
-
# git repo) also fall through silently — preflight has already
|
|
1414
|
-
# established we're inside a repo.
|
|
1415
|
-
#
|
|
1416
|
-
# Key is `release.publicClone` (camelCase) per git config naming
|
|
1417
|
-
# conventions; git 2.46+ rejects underscore-bearing keys at write
|
|
1418
|
-
# time, so the spec's informal `release.public_clone` wording maps
|
|
1419
|
-
# to this canonical form. Git config lookup is case-insensitive on
|
|
1420
|
-
# the trailing variable, so `release.publicclone` works too.
|
|
1421
|
-
try:
|
|
1422
|
-
out = subprocess.run(
|
|
1423
|
-
["git", "config", "--get", "release.publicClone"],
|
|
1424
|
-
capture_output=True,
|
|
1425
|
-
text=True,
|
|
1426
|
-
check=True,
|
|
1427
|
-
).stdout.strip()
|
|
1428
|
-
if out:
|
|
1429
|
-
candidates.append(
|
|
1430
|
-
("git config release.publicClone", pathlib.Path(out))
|
|
1431
|
-
)
|
|
1432
|
-
except subprocess.CalledProcessError:
|
|
1433
|
-
pass
|
|
1434
|
-
|
|
1435
|
-
# Source 3 — marker file under APP_DIR.
|
|
1436
|
-
marker = APP_DIR / "release-public-clone-path"
|
|
1437
|
-
if marker.exists():
|
|
1438
|
-
text = marker.read_text(encoding="utf-8").strip()
|
|
1439
|
-
if text:
|
|
1440
|
-
candidates.append((str(marker), pathlib.Path(text)))
|
|
1441
|
-
|
|
1442
|
-
for _source, path in candidates:
|
|
1443
|
-
if (path / ".git").exists():
|
|
1444
|
-
return path.resolve()
|
|
1445
|
-
# Bare repo — `<path>/HEAD` exists alongside `objects/`, `refs/`.
|
|
1446
|
-
if path.is_dir() and (path / "HEAD").exists():
|
|
1447
|
-
return path.resolve()
|
|
1448
|
-
# Lenient fallthrough — if the path simply exists but doesn't
|
|
1449
|
-
# match either layout, return it anyway. The next subprocess
|
|
1450
|
-
# (`git -C <path> ...`) will fail loudly with a clear error;
|
|
1451
|
-
# pre-validating here would only duplicate that diagnostic.
|
|
1452
|
-
if path.exists():
|
|
1453
|
-
return path.resolve()
|
|
1454
|
-
|
|
1455
|
-
marker_path = APP_DIR / "release-public-clone-path"
|
|
1456
|
-
print(
|
|
1457
|
-
"release: cannot discover public clone path; pass --public-clone "
|
|
1458
|
-
"<path>, set 'release.publicClone' in git config, or write the "
|
|
1459
|
-
f"path to {marker_path}",
|
|
1460
|
-
file=sys.stderr,
|
|
1461
|
-
)
|
|
1462
|
-
sys.exit(2)
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
def _release_discover_brew_clone(
|
|
1466
|
-
args: argparse.Namespace,
|
|
1467
|
-
) -> pathlib.Path | None:
|
|
1468
|
-
"""Resolve the brew tap clone path. Returns ``None`` if unconfigured.
|
|
1469
|
-
|
|
1470
|
-
Priority chain (mirrors :func:`_release_discover_public_clone`):
|
|
1471
|
-
1. ``--brew-clone <path>`` flag.
|
|
1472
|
-
2. ``git config --get release.brewClone``.
|
|
1473
|
-
3. ``$APP_DIR/release-brew-clone-path`` plain-text marker file.
|
|
1474
|
-
|
|
1475
|
-
Unlike :func:`_release_discover_public_clone` (which exits 2 when
|
|
1476
|
-
none of the sources resolve), this returns ``None`` — Phase 6 is a
|
|
1477
|
-
graceful skip, not a hard refusal, since release channels are
|
|
1478
|
-
added incrementally and not every operator has a brew tap clone
|
|
1479
|
-
on hand.
|
|
1480
|
-
"""
|
|
1481
|
-
if getattr(args, "brew_clone", None):
|
|
1482
|
-
return pathlib.Path(args.brew_clone).expanduser()
|
|
1483
|
-
|
|
1484
|
-
cfg = subprocess.run(
|
|
1485
|
-
["git", "config", "--get", "release.brewClone"],
|
|
1486
|
-
capture_output=True,
|
|
1487
|
-
text=True,
|
|
1488
|
-
check=False,
|
|
1489
|
-
)
|
|
1490
|
-
if cfg.returncode == 0 and cfg.stdout.strip():
|
|
1491
|
-
return pathlib.Path(cfg.stdout.strip()).expanduser()
|
|
1492
|
-
|
|
1493
|
-
marker = APP_DIR / "release-brew-clone-path"
|
|
1494
|
-
if marker.exists():
|
|
1495
|
-
path_str = marker.read_text(encoding="utf-8").strip()
|
|
1496
|
-
if path_str:
|
|
1497
|
-
return pathlib.Path(path_str).expanduser()
|
|
1498
|
-
|
|
1499
|
-
return None
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
def _release_phase_brew_done(
|
|
1503
|
-
version: str, brew_clone: pathlib.Path
|
|
1504
|
-
) -> bool:
|
|
1505
|
-
"""Phase 6 done iff the brew tap **remote** serves the ``vX.Y.Z``
|
|
1506
|
-
formula on its default branch AND carries the ``v<version>`` tag.
|
|
1507
|
-
|
|
1508
|
-
Tag presence alone is not enough — ``brew install`` reads the
|
|
1509
|
-
formula from the tap's default branch, NOT from the tag, so a
|
|
1510
|
-
half-failed push (tag landed, branch did not) would still serve
|
|
1511
|
-
the prior formula to users. Mirrors the
|
|
1512
|
-
:func:`_release_phase_mirror_done` pattern (Phase 3) and adds a
|
|
1513
|
-
branch-tip check on top.
|
|
1514
|
-
|
|
1515
|
-
Three-step check (each gates the next):
|
|
1516
|
-
|
|
1517
|
-
1. Local-file sniff — cheap pre-check; skip the network when
|
|
1518
|
-
the formula isn't even staged locally.
|
|
1519
|
-
2. ``git ls-remote --tags origin refs/tags/v<version>`` — tag
|
|
1520
|
-
must be on the remote.
|
|
1521
|
-
3. ``git ls-remote origin refs/heads/<branch>`` SHA == local
|
|
1522
|
-
clone's ``HEAD`` SHA — the local formula commit must be the
|
|
1523
|
-
remote default-branch tip. After a successful Phase 6 push,
|
|
1524
|
-
these match; after a half-failed push (branch fail, tag
|
|
1525
|
-
succeed), they diverge.
|
|
1526
|
-
|
|
1527
|
-
Returns ``False`` on any subprocess failure (no origin configured,
|
|
1528
|
-
network glitch); the caller proceeds to run the phase, whose own
|
|
1529
|
-
push step is independently idempotent.
|
|
1530
|
-
"""
|
|
1531
|
-
formula = brew_clone / "Formula" / "cctally.rb"
|
|
1532
|
-
if not formula.exists():
|
|
1533
|
-
return False
|
|
1534
|
-
if f"/v{version}.tar.gz" not in formula.read_text(encoding="utf-8"):
|
|
1535
|
-
return False
|
|
1536
|
-
try:
|
|
1537
|
-
origin = subprocess.check_output(
|
|
1538
|
-
["git", "-C", str(brew_clone), "remote", "get-url", "origin"],
|
|
1539
|
-
text=True,
|
|
1540
|
-
).strip()
|
|
1541
|
-
tag_out = subprocess.check_output(
|
|
1542
|
-
["git", "ls-remote", "--tags", origin, f"refs/tags/v{version}"],
|
|
1543
|
-
text=True,
|
|
1544
|
-
).strip()
|
|
1545
|
-
if not tag_out:
|
|
1546
|
-
return False
|
|
1547
|
-
local_head = subprocess.check_output(
|
|
1548
|
-
["git", "-C", str(brew_clone), "rev-parse", "HEAD"],
|
|
1549
|
-
text=True,
|
|
1550
|
-
).strip()
|
|
1551
|
-
branch = subprocess.check_output(
|
|
1552
|
-
["git", "-C", str(brew_clone), "rev-parse", "--abbrev-ref", "HEAD"],
|
|
1553
|
-
text=True,
|
|
1554
|
-
).strip()
|
|
1555
|
-
head_out = subprocess.check_output(
|
|
1556
|
-
["git", "ls-remote", origin, f"refs/heads/{branch}"],
|
|
1557
|
-
text=True,
|
|
1558
|
-
).strip()
|
|
1559
|
-
# ls-remote line format: "<sha>\trefs/heads/<branch>"; empty
|
|
1560
|
-
# output means the remote has no such branch (fresh tap).
|
|
1561
|
-
remote_head = head_out.split("\t", 1)[0] if head_out else ""
|
|
1562
|
-
return remote_head == local_head
|
|
1563
|
-
except subprocess.CalledProcessError:
|
|
1564
|
-
return False
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
def _release_phase_mirror_done(
|
|
1568
|
-
version: str, public_clone: pathlib.Path
|
|
1569
|
-
) -> bool:
|
|
1570
|
-
"""Phase 3 done iff the public clone's ``origin`` carries ``vX.Y.Z``.
|
|
1571
|
-
|
|
1572
|
-
Read-only signal — runs ``git ls-remote`` against the public clone's
|
|
1573
|
-
own ``origin`` (the public mirror's URL is the single source of
|
|
1574
|
-
truth here, per spec §9.1). Returns ``False`` on any subprocess
|
|
1575
|
-
failure (no origin remote configured, network glitch, etc.); the
|
|
1576
|
-
caller proceeds to run all three sub-steps, each idempotent on
|
|
1577
|
-
its own.
|
|
1578
|
-
"""
|
|
1579
|
-
tag = f"v{version}"
|
|
1580
|
-
try:
|
|
1581
|
-
public_origin = subprocess.check_output(
|
|
1582
|
-
["git", "-C", str(public_clone), "remote", "get-url", "origin"],
|
|
1583
|
-
text=True,
|
|
1584
|
-
).strip()
|
|
1585
|
-
out = subprocess.check_output(
|
|
1586
|
-
["git", "ls-remote", "--tags", public_origin, f"refs/tags/{tag}"],
|
|
1587
|
-
text=True,
|
|
1588
|
-
).strip()
|
|
1589
|
-
return bool(out)
|
|
1590
|
-
except subprocess.CalledProcessError:
|
|
1591
|
-
return False
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
def _release_run_phase_mirror(
|
|
1595
|
-
version: str, public_clone: pathlib.Path, remote: str
|
|
1596
|
-
) -> None:
|
|
1597
|
-
"""Phase 3 — replay private commits onto the public clone, then push
|
|
1598
|
-
the branch + tag (spec §9.1).
|
|
1599
|
-
|
|
1600
|
-
Three sub-steps, each its own subprocess; any failure halts with
|
|
1601
|
-
exit 3 so ``--resume`` can re-run from the failed step (each
|
|
1602
|
-
sub-step is independently idempotent).
|
|
1603
|
-
|
|
1604
|
-
3a. ``bin/cctally-mirror-public --yes <public-clone>`` — replay
|
|
1605
|
-
private commits onto the local public clone. ``--yes`` is
|
|
1606
|
-
mandatory in non-interactive context (the mirror tool prompts
|
|
1607
|
-
``apply? [y/N]`` otherwise).
|
|
1608
|
-
3b. ``git -C <public-clone> push origin <branch>`` — push the
|
|
1609
|
-
public-clone branch to public origin. The branch is read
|
|
1610
|
-
dynamically via ``git -C <public-clone> rev-parse --abbrev-ref
|
|
1611
|
-
HEAD`` rather than hardcoded ``main``, since some operators
|
|
1612
|
-
may run their public clone on a non-default branch.
|
|
1613
|
-
3c. ``git -C <public-clone> push origin refs/tags/v<version>``
|
|
1614
|
-
— push the new tag.
|
|
1615
|
-
|
|
1616
|
-
The ``remote`` arg is currently unused (the public-clone push always
|
|
1617
|
-
targets the clone's own ``origin``); kept in the signature for
|
|
1618
|
-
parity with ``_release_run_phase_tag`` and possible future
|
|
1619
|
-
multi-remote scenarios.
|
|
1620
|
-
"""
|
|
1621
|
-
del remote # spec §9.1 — public-clone push always targets `origin`.
|
|
1622
|
-
if _release_phase_mirror_done(version, public_clone):
|
|
1623
|
-
print(
|
|
1624
|
-
f"release: mirror ✓ (already done — v{version} on public origin)"
|
|
1625
|
-
)
|
|
1626
|
-
return
|
|
1627
|
-
|
|
1628
|
-
print("release: mirror push")
|
|
1629
|
-
|
|
1630
|
-
# Locate `bin/cctally-mirror-public` alongside this script. Resolve
|
|
1631
|
-
# via __file__ so symlinked invocations (~/.local/bin/cctally) still
|
|
1632
|
-
# find the in-repo sibling.
|
|
1633
|
-
mirror_tool = (
|
|
1634
|
-
pathlib.Path(__file__).resolve().parent / "cctally-mirror-public"
|
|
1635
|
-
)
|
|
1636
|
-
if not mirror_tool.exists():
|
|
1637
|
-
print(
|
|
1638
|
-
f"release: cannot find {mirror_tool}",
|
|
1639
|
-
file=sys.stderr,
|
|
1640
|
-
)
|
|
1641
|
-
sys.exit(3)
|
|
1642
|
-
|
|
1643
|
-
# Step 3a — replay private commits onto the public clone.
|
|
1644
|
-
rc = subprocess.call(
|
|
1645
|
-
[str(mirror_tool), "--yes", "--public-clone", str(public_clone)]
|
|
1646
|
-
)
|
|
1647
|
-
if rc != 0:
|
|
1648
|
-
print(
|
|
1649
|
-
f"release: mirror replay (3a) failed (exit {rc}); "
|
|
1650
|
-
"see output above",
|
|
1651
|
-
file=sys.stderr,
|
|
1652
|
-
)
|
|
1653
|
-
sys.exit(3)
|
|
1654
|
-
|
|
1655
|
-
# Step 3b — push the public-clone branch to public origin. The branch
|
|
1656
|
-
# is read dynamically (don't hardcode `main`).
|
|
1657
|
-
try:
|
|
1658
|
-
branch = subprocess.check_output(
|
|
1659
|
-
["git", "-C", str(public_clone), "rev-parse", "--abbrev-ref", "HEAD"],
|
|
1660
|
-
text=True,
|
|
1661
|
-
).strip()
|
|
1662
|
-
except subprocess.CalledProcessError as e:
|
|
1663
|
-
print(
|
|
1664
|
-
f"release: mirror branch lookup failed (exit {e.returncode})",
|
|
1665
|
-
file=sys.stderr,
|
|
1666
|
-
)
|
|
1667
|
-
sys.exit(3)
|
|
1668
|
-
rc = subprocess.call(
|
|
1669
|
-
["git", "-C", str(public_clone), "push", "origin", branch]
|
|
1670
|
-
)
|
|
1671
|
-
if rc != 0:
|
|
1672
|
-
print(
|
|
1673
|
-
f"release: mirror push branch (3b) failed (exit {rc})",
|
|
1674
|
-
file=sys.stderr,
|
|
1675
|
-
)
|
|
1676
|
-
sys.exit(3)
|
|
1677
|
-
|
|
1678
|
-
# Step 3c — push the new tag.
|
|
1679
|
-
tag = f"v{version}"
|
|
1680
|
-
rc = subprocess.call(
|
|
1681
|
-
[
|
|
1682
|
-
"git", "-C", str(public_clone),
|
|
1683
|
-
"push", "origin", f"refs/tags/{tag}",
|
|
1684
|
-
]
|
|
1685
|
-
)
|
|
1686
|
-
if rc != 0:
|
|
1687
|
-
print(
|
|
1688
|
-
f"release: mirror push tag (3c) failed (exit {rc})",
|
|
1689
|
-
file=sys.stderr,
|
|
1690
|
-
)
|
|
1691
|
-
sys.exit(3)
|
|
1692
|
-
|
|
1693
|
-
print(f"release: mirror ✓ (v{version} propagated)")
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
def _release_phase_gh_done(version: str) -> bool:
|
|
1697
|
-
"""Phase-4 read-only signal: ``gh release view vX.Y.Z`` returns 0.
|
|
1698
|
-
|
|
1699
|
-
Read-only — issues a `gh release view` against the public repo and
|
|
1700
|
-
treats exit 0 as "release exists." Suppresses stderr/stdout because
|
|
1701
|
-
the call is purely a probe; the operator-visible state is recorded
|
|
1702
|
-
in ``_release_run_phase_gh``'s own logging. A never-authed operator
|
|
1703
|
-
sees this return False (gh exits non-zero on auth error), the helper
|
|
1704
|
-
falls through to its own auth probe, and the fallback path prints a
|
|
1705
|
-
copy-pasteable command — same UX whether the release simply doesn't
|
|
1706
|
-
exist yet or whether gh can't see it.
|
|
1707
|
-
"""
|
|
1708
|
-
rc = subprocess.call(
|
|
1709
|
-
["gh", "release", "view", f"v{version}", "--repo", PUBLIC_REPO],
|
|
1710
|
-
stdout=subprocess.DEVNULL,
|
|
1711
|
-
stderr=subprocess.DEVNULL,
|
|
1712
|
-
)
|
|
1713
|
-
return rc == 0
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
def _release_phase_npm_done(version: str) -> bool:
|
|
1717
|
-
"""True iff ``cctally@<version>`` is already published on npm.
|
|
1718
|
-
|
|
1719
|
-
Probes via ``npm view cctally@<v> dist.tarball --json``. Returns
|
|
1720
|
-
True when the command succeeds AND stdout is a JSON-quoted
|
|
1721
|
-
registry.npmjs.org URL. False on any other condition (timeout,
|
|
1722
|
-
npm not on PATH, version absent, etc.). Used as the idempotency
|
|
1723
|
-
short-circuit for Phase 5 (parity with ``_release_phase_gh_done``).
|
|
1724
|
-
"""
|
|
1725
|
-
try:
|
|
1726
|
-
result = subprocess.run(
|
|
1727
|
-
["npm", "view", f"cctally@{version}", "dist.tarball", "--json"],
|
|
1728
|
-
capture_output=True,
|
|
1729
|
-
text=True,
|
|
1730
|
-
check=False,
|
|
1731
|
-
timeout=15,
|
|
1732
|
-
)
|
|
1733
|
-
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
1734
|
-
return False
|
|
1735
|
-
if result.returncode != 0:
|
|
1736
|
-
return False
|
|
1737
|
-
out = result.stdout.strip()
|
|
1738
|
-
return (
|
|
1739
|
-
out.startswith('"')
|
|
1740
|
-
and out.endswith('"')
|
|
1741
|
-
and "registry.npmjs.org" in out
|
|
1742
|
-
)
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
def _release_run_phase_gh(version: str, body: str) -> int:
|
|
1746
|
-
"""Phase 4 — ``gh release create`` with auth fallback (spec §9.2).
|
|
1747
|
-
|
|
1748
|
-
Returns:
|
|
1749
|
-
- ``0`` on successful publish OR auth-fallback (don't fail the
|
|
1750
|
-
whole release on missing gh auth — phases 1-3 already succeeded;
|
|
1751
|
-
Phase 4 is polish).
|
|
1752
|
-
- ``3`` on hard failure of ``gh release create`` after auth was
|
|
1753
|
-
confirmed OK (network glitch, server-side rejection, etc.); the
|
|
1754
|
-
operator can address it then re-run ``cctally release --resume``.
|
|
1755
|
-
|
|
1756
|
-
Idempotent: ``_release_phase_gh_done`` short-circuits when the
|
|
1757
|
-
release already exists. The body is passed in (not re-fetched here);
|
|
1758
|
-
the caller is responsible for extracting it from CHANGELOG so the
|
|
1759
|
-
body-canonical-three-sources invariant (spec §7.4) is preserved
|
|
1760
|
-
across phases 1, 2, and 4.
|
|
1761
|
-
|
|
1762
|
-
Auth-mismatch semantics (spec §9.3): if ``gh release view`` finds
|
|
1763
|
-
an existing release whose body differs from the current CHANGELOG
|
|
1764
|
-
section, treat the existing as authoritative — no ``gh release
|
|
1765
|
-
edit`` rewrite. Body divergence after the fact is a separate
|
|
1766
|
-
concern that warrants explicit operator action.
|
|
1767
|
-
"""
|
|
1768
|
-
if _release_phase_gh_done(version):
|
|
1769
|
-
url = f"https://github.com/{PUBLIC_REPO}/releases/tag/v{version}"
|
|
1770
|
-
print(f"release: gh release ✓ (already published — {url})")
|
|
1771
|
-
return 0
|
|
1772
|
-
|
|
1773
|
-
print("release: gh release")
|
|
1774
|
-
|
|
1775
|
-
# Auth probe — both must succeed for the operator to be able to
|
|
1776
|
-
# write to the public repo.
|
|
1777
|
-
auth_status_ok = (
|
|
1778
|
-
subprocess.call(
|
|
1779
|
-
["gh", "auth", "status", "--hostname", "github.com"],
|
|
1780
|
-
stdout=subprocess.DEVNULL,
|
|
1781
|
-
stderr=subprocess.DEVNULL,
|
|
1782
|
-
)
|
|
1783
|
-
== 0
|
|
1784
|
-
)
|
|
1785
|
-
repo_access_ok = (
|
|
1786
|
-
subprocess.call(
|
|
1787
|
-
["gh", "api", f"repos/{PUBLIC_REPO}"],
|
|
1788
|
-
stdout=subprocess.DEVNULL,
|
|
1789
|
-
stderr=subprocess.DEVNULL,
|
|
1790
|
-
)
|
|
1791
|
-
== 0
|
|
1792
|
-
)
|
|
1793
|
-
if not (auth_status_ok and repo_access_ok):
|
|
1794
|
-
cctally._release_print_gh_fallback(version, body)
|
|
1795
|
-
return 0
|
|
1796
|
-
|
|
1797
|
-
# Happy path. Notes go through a tmpfile to dodge shell escaping.
|
|
1798
|
-
notes_file = pathlib.Path(tempfile.gettempdir()) / (
|
|
1799
|
-
f"release-notes-v{version}-{os.getpid()}.md"
|
|
1800
|
-
)
|
|
1801
|
-
notes_file.write_text(body, encoding="utf-8")
|
|
1802
|
-
try:
|
|
1803
|
-
cmd = [
|
|
1804
|
-
"gh", "release", "create", f"v{version}",
|
|
1805
|
-
"--repo", PUBLIC_REPO,
|
|
1806
|
-
"--title", f"v{version}",
|
|
1807
|
-
"--notes-file", str(notes_file),
|
|
1808
|
-
]
|
|
1809
|
-
if "-" in version:
|
|
1810
|
-
cmd.append("--prerelease")
|
|
1811
|
-
rc = subprocess.call(cmd)
|
|
1812
|
-
if rc != 0:
|
|
1813
|
-
print(
|
|
1814
|
-
f"release: gh release create failed (exit {rc}); "
|
|
1815
|
-
"--resume to retry",
|
|
1816
|
-
file=sys.stderr,
|
|
1817
|
-
)
|
|
1818
|
-
return 3
|
|
1819
|
-
finally:
|
|
1820
|
-
notes_file.unlink(missing_ok=True)
|
|
1821
|
-
|
|
1822
|
-
url = f"https://github.com/{PUBLIC_REPO}/releases/tag/v{version}"
|
|
1823
|
-
print(f"release: gh release ✓ ({url})")
|
|
1824
|
-
return 0
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
def _release_run_phase_npm(
|
|
1828
|
-
version: str,
|
|
1829
|
-
public_clone: pathlib.Path,
|
|
1830
|
-
*,
|
|
1831
|
-
dist_tag: str,
|
|
1832
|
-
) -> int:
|
|
1833
|
-
"""Phase 5 — wait for the public-repo GHA workflow to publish ``cctally@<v>``.
|
|
1834
|
-
|
|
1835
|
-
Phase 3 pushes ``v<version>`` to ``omrikais/cctally``; the workflow at
|
|
1836
|
-
``.github/workflows/release-npm.yml`` fires on tag-push and runs
|
|
1837
|
-
``npm publish --provenance`` via OIDC trusted publisher (no NPM_TOKEN,
|
|
1838
|
-
no operator 2FA round-trip — fixes the passkey-in-subprocess failure
|
|
1839
|
-
mode where npm 2FA blocks ``npm publish`` from a non-interactive
|
|
1840
|
-
subprocess).
|
|
1841
|
-
|
|
1842
|
-
Phase 5 here is observation-only: poll ``npm view cctally@<v>`` until
|
|
1843
|
-
it appears, with timeout. ``cctally`` never invokes ``npm publish``
|
|
1844
|
-
locally anymore — Trusted Publisher binds the right to publish to the
|
|
1845
|
-
public-repo workflow, not to the operator's `npm login` token.
|
|
1846
|
-
|
|
1847
|
-
Returns ``0`` on observed success OR poll-timeout (soft-success: phases
|
|
1848
|
-
1-4 landed; the workflow is either succeeding or visibly failing on
|
|
1849
|
-
github.com; ``--resume`` re-checks the registry).
|
|
1850
|
-
|
|
1851
|
-
Timing overridable via ``CCTALLY_RELEASE_NPM_POLL_TIMEOUT_S`` and
|
|
1852
|
-
``CCTALLY_RELEASE_NPM_POLL_INTERVAL_S`` env vars.
|
|
1853
|
-
"""
|
|
1854
|
-
print(f"phase 5: await npm publish via GHA (tag={dist_tag})")
|
|
1855
|
-
if _release_phase_npm_done(version):
|
|
1856
|
-
print(f" cctally@{version} already on npm — skipping.")
|
|
1857
|
-
return 0
|
|
1858
|
-
|
|
1859
|
-
timeout_s, interval_s = cctally._release_npm_poll_timing()
|
|
1860
|
-
deadline = time.monotonic() + timeout_s
|
|
1861
|
-
while True:
|
|
1862
|
-
if _release_phase_npm_done(version):
|
|
1863
|
-
print(f" cctally@{version} on npm registry ✓")
|
|
1864
|
-
return 0
|
|
1865
|
-
if time.monotonic() >= deadline:
|
|
1866
|
-
print(
|
|
1867
|
-
f"\n timed out after {timeout_s:.0f}s waiting for "
|
|
1868
|
-
f"cctally@{version} on npm. The GHA workflow may still be "
|
|
1869
|
-
f"running or have failed — check:\n"
|
|
1870
|
-
f" https://github.com/{PUBLIC_REPO}/actions\n"
|
|
1871
|
-
f" Re-run `cctally release --resume` once the workflow "
|
|
1872
|
-
f"completes, or for emergency manual publish:\n"
|
|
1873
|
-
f" cd {public_clone} && npm publish --access public "
|
|
1874
|
-
f"--tag {dist_tag}\n",
|
|
1875
|
-
file=sys.stderr,
|
|
1876
|
-
)
|
|
1877
|
-
return 0
|
|
1878
|
-
time.sleep(interval_s)
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
def _release_run_phase_brew(
|
|
1882
|
-
version: str,
|
|
1883
|
-
brew_clone: pathlib.Path | None,
|
|
1884
|
-
allow_downgrade: bool = False,
|
|
1885
|
-
) -> int:
|
|
1886
|
-
"""Phase 6 — render ``Formula/cctally.rb`` and push to the brew tap.
|
|
1887
|
-
|
|
1888
|
-
Idempotent. Pre-releases are skipped at the cmd_release call site —
|
|
1889
|
-
this function never runs for them.
|
|
1890
|
-
|
|
1891
|
-
Phase semantics:
|
|
1892
|
-
- ``brew_clone is None`` — graceful skip (no error). Operator can
|
|
1893
|
-
opt in later by setting ``release.brewClone`` in git config.
|
|
1894
|
-
- Done-signal short-circuit — prints "already at vX.Y.Z" and
|
|
1895
|
-
returns 0 when ``Formula/cctally.rb`` already references this
|
|
1896
|
-
version (idempotency under ``--resume``).
|
|
1897
|
-
- Dirty working tree — refuses with exit 2 and points the operator
|
|
1898
|
-
at ``--resume``.
|
|
1899
|
-
- **Monotonic-version gate (issue #30).** Refuses with exit 2 when
|
|
1900
|
-
the existing on-disk formula's URL pins a *higher* SemVer than
|
|
1901
|
-
``version``. Catches the f02b2f1 regression class — operator
|
|
1902
|
-
runs Phase 6 from a stale CHANGELOG / fixture leak / accidental
|
|
1903
|
-
old branch and would otherwise silently write a lower version
|
|
1904
|
-
over a higher one. Override via ``allow_downgrade=True``
|
|
1905
|
-
(operator-driven, for genuine yank/revert cases).
|
|
1906
|
-
- Push failure — auth-fallback parity with Phase 4: prints the
|
|
1907
|
-
exact recovery command and returns 0. Phases 1-5 already
|
|
1908
|
-
succeeded, the release IS published from the user's
|
|
1909
|
-
perspective; the brew tap is the third channel and treated as
|
|
1910
|
-
polish.
|
|
1911
|
-
|
|
1912
|
-
Returns:
|
|
1913
|
-
- ``0`` on success, graceful skip, idempotent short-circuit, OR
|
|
1914
|
-
push fallback.
|
|
1915
|
-
- ``2`` on dirty-working-tree refusal OR downgrade-gate refusal.
|
|
1916
|
-
"""
|
|
1917
|
-
print("phase 6: brew formula bump")
|
|
1918
|
-
if brew_clone is None:
|
|
1919
|
-
print(
|
|
1920
|
-
" brew tap clone not configured. Set with:\n"
|
|
1921
|
-
" git config release.brewClone /path/to/homebrew-cctally\n"
|
|
1922
|
-
" Skipping phase 6 (no error).",
|
|
1923
|
-
file=sys.stderr,
|
|
1924
|
-
)
|
|
1925
|
-
return 0
|
|
1926
|
-
if _release_phase_brew_done(version, brew_clone):
|
|
1927
|
-
print(f" Formula/cctally.rb already at v{version} on tap — skipping.")
|
|
1928
|
-
return 0
|
|
1929
|
-
|
|
1930
|
-
# Refuse on dirty working tree — we're about to write the formula
|
|
1931
|
-
# and commit; mixing operator-staged work into our commit is a
|
|
1932
|
-
# footgun. Resume after the operator resolves.
|
|
1933
|
-
status = subprocess.run(
|
|
1934
|
-
["git", "-C", str(brew_clone), "status", "--porcelain"],
|
|
1935
|
-
capture_output=True,
|
|
1936
|
-
text=True,
|
|
1937
|
-
check=True,
|
|
1938
|
-
)
|
|
1939
|
-
if status.stdout.strip():
|
|
1940
|
-
print(
|
|
1941
|
-
f" brew clone has uncommitted changes:\n{status.stdout}\n"
|
|
1942
|
-
" Resolve and re-run `cctally release --resume`.",
|
|
1943
|
-
file=sys.stderr,
|
|
1944
|
-
)
|
|
1945
|
-
return 2
|
|
1946
|
-
|
|
1947
|
-
formula_path = brew_clone / "Formula" / "cctally.rb"
|
|
1948
|
-
local_at_version = (
|
|
1949
|
-
formula_path.exists()
|
|
1950
|
-
and f"/v{version}.tar.gz"
|
|
1951
|
-
in formula_path.read_text(encoding="utf-8")
|
|
1952
|
-
)
|
|
1953
|
-
|
|
1954
|
-
if local_at_version:
|
|
1955
|
-
# Resume after a push failure (the done-check verifies remote
|
|
1956
|
-
# tag, so we only reach here when the local commit is in place
|
|
1957
|
-
# but the tap origin hasn't seen it). Skip render + commit; go
|
|
1958
|
-
# straight to (re)tag + push. Re-rendering would no-op the
|
|
1959
|
-
# commit (`git commit` exits 1 with nothing to commit), so
|
|
1960
|
-
# short-circuiting is also what keeps the function tidy.
|
|
1961
|
-
print(
|
|
1962
|
-
f" local formula already at v{version}; re-pushing to tap…"
|
|
1963
|
-
)
|
|
1964
|
-
else:
|
|
1965
|
-
# Monotonic-version gate (issue #30). The brew tap regressed
|
|
1966
|
-
# from v1.3.0 → v1.0.0 twice in one day via this code path; the
|
|
1967
|
-
# equality fingerprint above (`local_at_version`) is False on a
|
|
1968
|
-
# downgrade, so without this gate we'd silently overwrite a
|
|
1969
|
-
# higher version. Compare the existing formula's URL-pinned
|
|
1970
|
-
# SemVer against `version` (SemVer-aware so prereleases sort
|
|
1971
|
-
# below their stable counterpart per §11.4); refuse with exit 2
|
|
1972
|
-
# when the on-disk version is strictly higher. Unparseable
|
|
1973
|
-
# formulas are treated as unversioned and allowed through.
|
|
1974
|
-
if formula_path.exists():
|
|
1975
|
-
existing_text = formula_path.read_text(encoding="utf-8")
|
|
1976
|
-
existing_v = cctally._release_extract_formula_version(existing_text)
|
|
1977
|
-
if existing_v is not None:
|
|
1978
|
-
try:
|
|
1979
|
-
existing_key = _release_semver_sort_key(
|
|
1980
|
-
_release_parse_semver(existing_v)
|
|
1981
|
-
)
|
|
1982
|
-
target_key = _release_semver_sort_key(
|
|
1983
|
-
_release_parse_semver(version)
|
|
1984
|
-
)
|
|
1985
|
-
except ValueError:
|
|
1986
|
-
existing_key = target_key = None
|
|
1987
|
-
if (
|
|
1988
|
-
existing_key is not None
|
|
1989
|
-
and target_key is not None
|
|
1990
|
-
and existing_key > target_key
|
|
1991
|
-
and not allow_downgrade
|
|
1992
|
-
):
|
|
1993
|
-
print(
|
|
1994
|
-
f" refuse: existing formula pins v{existing_v}, "
|
|
1995
|
-
f"target is v{version} (downgrade).\n"
|
|
1996
|
-
" Common causes: stale CHANGELOG.md in this clone, "
|
|
1997
|
-
"fixture leak into a real `release.brewClone`, or an "
|
|
1998
|
-
"accidental old branch.\n"
|
|
1999
|
-
" Verify intent, then re-run with "
|
|
2000
|
-
"`--allow-formula-downgrade` to override "
|
|
2001
|
-
"(yank / revert cases). See issue #30.",
|
|
2002
|
-
file=sys.stderr,
|
|
2003
|
-
)
|
|
2004
|
-
return 2
|
|
2005
|
-
if (
|
|
2006
|
-
existing_key is not None
|
|
2007
|
-
and target_key is not None
|
|
2008
|
-
and existing_key > target_key
|
|
2009
|
-
and allow_downgrade
|
|
2010
|
-
):
|
|
2011
|
-
print(
|
|
2012
|
-
f" WARNING: writing v{version} over existing v{existing_v} "
|
|
2013
|
-
"(--allow-formula-downgrade); intentional yank?",
|
|
2014
|
-
file=sys.stderr,
|
|
2015
|
-
)
|
|
2016
|
-
print(f" computing sha256 of v{version} archive…")
|
|
2017
|
-
sha = cctally._release_compute_brew_sha256(version)
|
|
2018
|
-
|
|
2019
|
-
template = cctally._homebrew_template_path().read_text(encoding="utf-8")
|
|
2020
|
-
rendered = (
|
|
2021
|
-
template
|
|
2022
|
-
.replace("<<VERSION>>", version)
|
|
2023
|
-
.replace("<<SHA256>>", sha)
|
|
2024
|
-
)
|
|
2025
|
-
formula_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2026
|
-
formula_path.write_text(rendered, encoding="utf-8")
|
|
2027
|
-
|
|
2028
|
-
subprocess.run(
|
|
2029
|
-
["git", "-C", str(brew_clone), "add", "Formula/cctally.rb"],
|
|
2030
|
-
check=True,
|
|
2031
|
-
)
|
|
2032
|
-
subprocess.run(
|
|
2033
|
-
["git", "-C", str(brew_clone), "commit", "-m",
|
|
2034
|
-
f"chore(formula): cctally {version}"],
|
|
2035
|
-
check=True,
|
|
2036
|
-
)
|
|
2037
|
-
# Tag is best-effort — re-running after a partial publish should
|
|
2038
|
-
# not fail just because the tag already exists locally.
|
|
2039
|
-
#
|
|
2040
|
-
# Annotated form with `-m` (issue #25): plain `git tag <name>` is
|
|
2041
|
-
# silently upgraded to `git tag -s <name>` under operator-global
|
|
2042
|
-
# `tag.gpgsign=true` and demands a message via editor. The release
|
|
2043
|
-
# script has no editor stdin, so git aborts with `fatal: no tag
|
|
2044
|
-
# message?` — the atomic push refspec then fails with "src refspec
|
|
2045
|
-
# does not match any" because the local tag was never created, and
|
|
2046
|
-
# the auth-fallback branch below silently swallows the failure as
|
|
2047
|
-
# exit 0. Mirrors Phase 2's signing detection (signing_key +
|
|
2048
|
-
# tag.gpgsign → -s, else fall back), with one defensive divergence:
|
|
2049
|
-
# the fallback uses --no-sign (not bare -a) so the tag still lands
|
|
2050
|
-
# under tag.gpgsign=true without a usable signing key configured.
|
|
2051
|
-
# Brew install reads the formula off the tap's default branch, not
|
|
2052
|
-
# the tag, so signing the tap tag is operationally moot — it exists
|
|
2053
|
-
# for history bookkeeping and atomic-push transport.
|
|
2054
|
-
signing_key = subprocess.run(
|
|
2055
|
-
["git", "-C", str(brew_clone), "config", "--get", "user.signingkey"],
|
|
2056
|
-
capture_output=True,
|
|
2057
|
-
text=True,
|
|
2058
|
-
).stdout.strip()
|
|
2059
|
-
tag_gpgsign = (
|
|
2060
|
-
subprocess.run(
|
|
2061
|
-
["git", "-C", str(brew_clone), "config", "--get", "tag.gpgsign"],
|
|
2062
|
-
capture_output=True,
|
|
2063
|
-
text=True,
|
|
2064
|
-
)
|
|
2065
|
-
.stdout.strip()
|
|
2066
|
-
.lower()
|
|
2067
|
-
== "true"
|
|
2068
|
-
)
|
|
2069
|
-
sign_flag = "-s" if (signing_key and tag_gpgsign) else "--no-sign"
|
|
2070
|
-
subprocess.run(
|
|
2071
|
-
["git", "-C", str(brew_clone), "tag", sign_flag, "-a", "-m",
|
|
2072
|
-
f"cctally v{version}", f"v{version}"],
|
|
2073
|
-
check=False,
|
|
2074
|
-
)
|
|
2075
|
-
# Single ATOMIC push of branch + tag in one transaction — the
|
|
2076
|
-
# remote either accepts both refs or neither (server-side atomic
|
|
2077
|
-
# push, `--atomic`). Avoids the half-failed-push asymmetry that a
|
|
2078
|
-
# split branch-push + tag-push pair admits: a tag landing without
|
|
2079
|
-
# the branch landing would leave `brew install` serving the OLD
|
|
2080
|
-
# formula off the tap's default branch even though the remote
|
|
2081
|
-
# carries the new tag. Tag refspec is explicit (`src:dst`) for
|
|
2082
|
-
# atomic-push semantics — `--atomic` requires named refs, not the
|
|
2083
|
-
# implicit `--follow-tags` path.
|
|
2084
|
-
push = subprocess.run(
|
|
2085
|
-
["git", "-C", str(brew_clone), "push", "--atomic", "origin",
|
|
2086
|
-
"HEAD", f"refs/tags/v{version}:refs/tags/v{version}"],
|
|
2087
|
-
check=False,
|
|
2088
|
-
)
|
|
2089
|
-
if push.returncode != 0:
|
|
2090
|
-
# If the local tag isn't there (e.g., the operator hit the
|
|
2091
|
-
# tag.gpgsign edge case from issue #25 even with the fix), the
|
|
2092
|
-
# plain push refspec fails. Surface a tag-create fallback in
|
|
2093
|
-
# the hint so copy-paste recovery is self-contained.
|
|
2094
|
-
print(
|
|
2095
|
-
f"\n push failed. Manual recovery:\n"
|
|
2096
|
-
f" # If local tag v{version} is missing (e.g., gpgsign issue):\n"
|
|
2097
|
-
f" git -C {brew_clone} tag --no-sign -a -m \"cctally v{version}\" v{version}\n"
|
|
2098
|
-
f" # Then push:\n"
|
|
2099
|
-
f" git -C {brew_clone} push --atomic origin HEAD "
|
|
2100
|
-
f"refs/tags/v{version}:refs/tags/v{version}\n",
|
|
2101
|
-
file=sys.stderr,
|
|
2102
|
-
)
|
|
2103
|
-
return 0 # auth-fallback semantics; parity with Phase 4.
|
|
2104
|
-
|
|
2105
|
-
return 0
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
def _release_extract_body_from_changelog(version: str) -> str:
|
|
2109
|
-
"""Re-read the canonical body for ``version`` from CHANGELOG.md.
|
|
2110
|
-
|
|
2111
|
-
Same parse + canonicalize path as Phases 1 and 2 (spec §7.4 — the
|
|
2112
|
-
body-canonical-three-sources invariant). Refuses with exit 3 if
|
|
2113
|
-
the section isn't found; ``--resume`` is the recovery once the
|
|
2114
|
-
operator re-stamps.
|
|
2115
|
-
"""
|
|
2116
|
-
text = CHANGELOG_PATH.read_text(encoding="utf-8")
|
|
2117
|
-
parsed = cctally._release_parse_changelog(text)
|
|
2118
|
-
section = next(
|
|
2119
|
-
(
|
|
2120
|
-
s for s in parsed["sections"]
|
|
2121
|
-
if s["heading"].startswith(f"## [{version}]")
|
|
2122
|
-
),
|
|
2123
|
-
None,
|
|
2124
|
-
)
|
|
2125
|
-
if section is None:
|
|
2126
|
-
print(
|
|
2127
|
-
f"release: cannot find [{version}] section in CHANGELOG.md",
|
|
2128
|
-
file=sys.stderr,
|
|
2129
|
-
)
|
|
2130
|
-
sys.exit(3)
|
|
2131
|
-
return cctally._release_canonical_body(section)
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
def cmd_release(args: argparse.Namespace) -> int:
|
|
2135
|
-
"""Dispatch thunk — actual implementation in bin/_cctally_release.py.
|
|
2136
|
-
|
|
2137
|
-
Lazy-load + invoke. Test/harness code that calls cctally.cmd_release
|
|
2138
|
-
directly hits this thunk; the underlying _cctally_release.cmd_release
|
|
2139
|
-
handles the work. The thunk fires only when cmd_release is invoked,
|
|
2140
|
-
so the lazy module's parse cost is paid only on `cctally release ...`
|
|
2141
|
-
or test access — not on every `cctally <other-subcmd>` invocation.
|
|
2142
|
-
|
|
2143
|
-
Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §4.1
|
|
2144
|
-
"""
|
|
2145
|
-
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`.
|
|
2146
807
|
|
|
2147
808
|
|
|
2148
809
|
# Files to scan when detecting the legacy status-line snippet (Section 5).
|
|
@@ -2317,7 +978,6 @@ def _decode_escaped_cwd(dir_name: str) -> str:
|
|
|
2317
978
|
return "/" + stripped.replace("-", "/")
|
|
2318
979
|
|
|
2319
980
|
|
|
2320
|
-
|
|
2321
981
|
def _sum_cost_for_range(
|
|
2322
982
|
start: dt.datetime,
|
|
2323
983
|
end: dt.datetime,
|
|
@@ -2400,7 +1060,6 @@ def _resolve_primary_model_for_block(
|
|
|
2400
1060
|
return row["model"]
|
|
2401
1061
|
|
|
2402
1062
|
|
|
2403
|
-
|
|
2404
1063
|
def _read_keychain_oauth_blob() -> str | None:
|
|
2405
1064
|
"""Read the Claude Code keychain entry on macOS via `security`.
|
|
2406
1065
|
|
|
@@ -5016,13 +3675,30 @@ def cmd_blocks(args: argparse.Namespace) -> int:
|
|
|
5016
3675
|
range_start - BLOCK_DURATION, range_end + BLOCK_DURATION,
|
|
5017
3676
|
)
|
|
5018
3677
|
|
|
5019
|
-
# Group into blocks
|
|
5020
|
-
|
|
5021
|
-
|
|
3678
|
+
# Group into blocks via the view-model kernel (issue #56). The
|
|
3679
|
+
# heuristic-aware ``aggregated`` tuple holds the full Block list
|
|
3680
|
+
# (gaps included, oldest-first) — same shape the JSON / table
|
|
3681
|
+
# renderers expect. We materialize back to a list because
|
|
3682
|
+
# ``_maybe_swap_active_block_to_canonical`` mutates in-place.
|
|
3683
|
+
#
|
|
3684
|
+
# ``skip_rows=True`` (issue #60 review fix) opts out of the
|
|
3685
|
+
# dashboard-row construction inside ``build_blocks_view`` — the
|
|
3686
|
+
# per-block per-model enrichment that scans every entry per
|
|
3687
|
+
# non-gap block (O(B × N)). The CLI never reads ``view.rows``
|
|
3688
|
+
# (only ``view.aggregated`` here), so on large all-history blocks
|
|
3689
|
+
# runs we avoid quadratic-ish work we'd discard.
|
|
3690
|
+
view = build_blocks_view(
|
|
3691
|
+
all_entries,
|
|
3692
|
+
now_utc=now_utc,
|
|
5022
3693
|
recorded_windows=recorded_windows,
|
|
5023
3694
|
block_start_overrides=block_start_overrides,
|
|
5024
|
-
|
|
3695
|
+
range_start=range_start,
|
|
3696
|
+
range_end=range_end,
|
|
3697
|
+
display_tz=tz,
|
|
3698
|
+
mode="auto",
|
|
3699
|
+
skip_rows=True,
|
|
5025
3700
|
)
|
|
3701
|
+
blocks = list(view.aggregated)
|
|
5026
3702
|
|
|
5027
3703
|
# Bug E (v1.7.2 round-4): when the ACTIVE block is heuristic-anchored
|
|
5028
3704
|
# but a canonical ``five_hour_blocks`` row exists for the current 5h
|
|
@@ -5245,7 +3921,6 @@ def _parse_cli_date_range(
|
|
|
5245
3921
|
return range_start, range_end
|
|
5246
3922
|
|
|
5247
3923
|
|
|
5248
|
-
|
|
5249
3924
|
def cmd_daily(args: argparse.Namespace) -> int:
|
|
5250
3925
|
"""Show usage report grouped by display-timezone date."""
|
|
5251
3926
|
_share_validate_args(args)
|
|
@@ -5537,7 +4212,14 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
|
|
|
5537
4212
|
range_start, range_end = range
|
|
5538
4213
|
|
|
5539
4214
|
entries = get_codex_entries(range_start, range_end)
|
|
5540
|
-
|
|
4215
|
+
# Route through ``build_codex_daily_view`` (issue #58). The View
|
|
4216
|
+
# wraps ``_aggregate_codex_daily`` without changing it — preserves
|
|
4217
|
+
# LiteLLM token semantics, intentional dedup vs upstream, and
|
|
4218
|
+
# ``CODEX_LEGACY_FALLBACK_MODEL`` warning end-to-end.
|
|
4219
|
+
view = build_codex_daily_view(
|
|
4220
|
+
entries, now_utc=_command_as_of(), tz_name=tz_name,
|
|
4221
|
+
)
|
|
4222
|
+
days = list(view.rows) # asc — matches aggregator default
|
|
5541
4223
|
if args.order == "desc":
|
|
5542
4224
|
days = list(reversed(days))
|
|
5543
4225
|
|
|
@@ -5559,7 +4241,7 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
|
|
|
5559
4241
|
y, m, d = bucket.split("-")
|
|
5560
4242
|
return f"{_CODEX_MONTHS[int(m) - 1]} {int(d):02d},\n{y}"
|
|
5561
4243
|
|
|
5562
|
-
tz_label =
|
|
4244
|
+
tz_label = view.display_tz_label
|
|
5563
4245
|
title = f"Codex Token Usage Report - Daily (Timezone: {tz_label})"
|
|
5564
4246
|
print(_render_codex_bucket_table(
|
|
5565
4247
|
days,
|
|
@@ -5589,7 +4271,11 @@ def cmd_codex_monthly(args: argparse.Namespace) -> int:
|
|
|
5589
4271
|
range_start, range_end = range
|
|
5590
4272
|
|
|
5591
4273
|
entries = get_codex_entries(range_start, range_end)
|
|
5592
|
-
|
|
4274
|
+
# Route through ``build_codex_monthly_view`` (issue #58).
|
|
4275
|
+
view = build_codex_monthly_view(
|
|
4276
|
+
entries, now_utc=_command_as_of(), tz_name=tz_name,
|
|
4277
|
+
)
|
|
4278
|
+
months = list(view.rows)
|
|
5593
4279
|
if args.order == "desc":
|
|
5594
4280
|
months = list(reversed(months))
|
|
5595
4281
|
|
|
@@ -5611,7 +4297,7 @@ def cmd_codex_monthly(args: argparse.Namespace) -> int:
|
|
|
5611
4297
|
y, m = bucket.split("-")
|
|
5612
4298
|
return f"{_CODEX_MONTHS[int(m) - 1]}\n{y}"
|
|
5613
4299
|
|
|
5614
|
-
tz_label =
|
|
4300
|
+
tz_label = view.display_tz_label
|
|
5615
4301
|
title = f"Codex Token Usage Report - Monthly (Timezone: {tz_label})"
|
|
5616
4302
|
print(_render_codex_bucket_table(
|
|
5617
4303
|
months,
|
|
@@ -5644,7 +4330,12 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
|
|
|
5644
4330
|
week_start_idx = WEEKDAY_MAP[week_start_name]
|
|
5645
4331
|
|
|
5646
4332
|
entries = get_codex_entries(range_start, range_end)
|
|
5647
|
-
|
|
4333
|
+
# Route through ``build_codex_weekly_view`` (issue #58).
|
|
4334
|
+
view = build_codex_weekly_view(
|
|
4335
|
+
entries, now_utc=now_utc, tz_name=tz_name,
|
|
4336
|
+
week_start_idx=week_start_idx,
|
|
4337
|
+
)
|
|
4338
|
+
weeks = list(view.rows)
|
|
5648
4339
|
if args.order == "desc":
|
|
5649
4340
|
weeks = list(reversed(weeks))
|
|
5650
4341
|
|
|
@@ -5669,7 +4360,7 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
|
|
|
5669
4360
|
y, m, d = bucket.split("-")
|
|
5670
4361
|
return f"{_CODEX_MONTHS[int(m) - 1]} {int(d):02d},\n{y}"
|
|
5671
4362
|
|
|
5672
|
-
tz_label =
|
|
4363
|
+
tz_label = view.display_tz_label
|
|
5673
4364
|
title = f"Codex Token Usage Report - Weekly (Timezone: {tz_label})"
|
|
5674
4365
|
print(_render_codex_bucket_table(
|
|
5675
4366
|
weeks,
|
|
@@ -5699,8 +4390,13 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
|
|
|
5699
4390
|
range_start, range_end = range
|
|
5700
4391
|
|
|
5701
4392
|
entries = get_codex_entries(range_start, range_end)
|
|
5702
|
-
|
|
5703
|
-
#
|
|
4393
|
+
# Route through ``build_codex_session_view`` (issue #58). View rows
|
|
4394
|
+
# come descending by last_activity (aggregator default + upstream
|
|
4395
|
+
# parity); --order asc reverses.
|
|
4396
|
+
view = build_codex_session_view(
|
|
4397
|
+
entries, now_utc=_command_as_of(), tz_name=tz_name,
|
|
4398
|
+
)
|
|
4399
|
+
sessions = list(view.rows)
|
|
5704
4400
|
if args.order == "asc":
|
|
5705
4401
|
sessions = list(reversed(sessions))
|
|
5706
4402
|
|
|
@@ -5714,7 +4410,7 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
|
|
|
5714
4410
|
print(_codex_sessions_to_json(sessions))
|
|
5715
4411
|
return 0
|
|
5716
4412
|
|
|
5717
|
-
tz_label =
|
|
4413
|
+
tz_label = view.display_tz_label
|
|
5718
4414
|
# Upstream uses "Sessions" (plural) in the session banner title.
|
|
5719
4415
|
title = f"Codex Token Usage Report - Sessions (Timezone: {tz_label})"
|
|
5720
4416
|
print(_render_codex_session_table(
|
|
@@ -6423,7 +5119,6 @@ _diff_render_json = _lib_diff_kernel._diff_render_json
|
|
|
6423
5119
|
_diff_resolve_anchor = _lib_diff_kernel._diff_resolve_anchor
|
|
6424
5120
|
|
|
6425
5121
|
|
|
6426
|
-
|
|
6427
5122
|
def cmd_diff(args: argparse.Namespace) -> int:
|
|
6428
5123
|
"""Compare Claude usage between two windows."""
|
|
6429
5124
|
now_utc = _command_as_of()
|
|
@@ -8270,7 +6965,16 @@ def cmd_forecast(args: argparse.Namespace) -> int:
|
|
|
8270
6965
|
# sync_cache(conn) here — that prior call ran on the stats DB connection
|
|
8271
6966
|
# (wrong conn) and was a no-op for the real cache anyway.
|
|
8272
6967
|
|
|
8273
|
-
|
|
6968
|
+
# Route through ``build_forecast_view`` (issue #57). The View is the
|
|
6969
|
+
# kernel-pattern wrapper; ``view.output`` carries the existing
|
|
6970
|
+
# ``ForecastOutput`` math result so every downstream renderer here
|
|
6971
|
+
# (text / JSON / status-line / share) reuses the same projection,
|
|
6972
|
+
# verdict, budgets, and per-method rate fields without recomputing.
|
|
6973
|
+
view = build_forecast_view(
|
|
6974
|
+
conn, now_utc=now_utc, targets=tuple(targets),
|
|
6975
|
+
skip_sync=args.no_sync, display_tz=args._resolved_tz,
|
|
6976
|
+
)
|
|
6977
|
+
inputs = view.output.inputs if view.output is not None else None
|
|
8274
6978
|
if inputs is None:
|
|
8275
6979
|
# No snapshot for the current week.
|
|
8276
6980
|
if getattr(args, "format", None):
|
|
@@ -8336,12 +7040,12 @@ def cmd_forecast(args: argparse.Namespace) -> int:
|
|
|
8336
7040
|
print("forecast: no data for current week yet")
|
|
8337
7041
|
return 0
|
|
8338
7042
|
|
|
8339
|
-
output =
|
|
7043
|
+
output = view.output
|
|
8340
7044
|
|
|
8341
7045
|
# Shareable-reports gate: --format short-circuits the JSON / status-line /
|
|
8342
7046
|
# terminal dispatch via `_share_render_and_emit`. The mutex in
|
|
8343
7047
|
# `_add_share_args(has_status_line=True)` keeps `--format`, `--json`, and
|
|
8344
|
-
# `--status-line` from coexisting. The gate fires AFTER
|
|
7048
|
+
# `--status-line` from coexisting. The gate fires AFTER ``build_forecast_view``
|
|
8345
7049
|
# so the snapshot reuses the same projection math as the terminal/JSON
|
|
8346
7050
|
# paths — no parallel computation.
|
|
8347
7051
|
if getattr(args, "format", None):
|
|
@@ -8886,10 +7590,21 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
|
|
|
8886
7590
|
)
|
|
8887
7591
|
else:
|
|
8888
7592
|
period_end = _share_now_utc()
|
|
8889
|
-
|
|
7593
|
+
# Build a BlocksView from the API-anchored table rows
|
|
7594
|
+
# (issue #56). Reset-aware totals come from the table's
|
|
7595
|
+
# per-block columns (CLAUDE.md 5-hour gotcha block) so the
|
|
7596
|
+
# share snapshot's footer reads from the single typed
|
|
7597
|
+
# source rather than re-summing inline.
|
|
7598
|
+
view = build_blocks_view_from_table_rows(
|
|
8890
7599
|
block_dicts,
|
|
8891
7600
|
period_start=period_start,
|
|
8892
7601
|
period_end=period_end,
|
|
7602
|
+
display_tz=args._resolved_tz,
|
|
7603
|
+
)
|
|
7604
|
+
snap = _build_five_hour_blocks_snapshot(
|
|
7605
|
+
view,
|
|
7606
|
+
period_start=period_start,
|
|
7607
|
+
period_end=period_end,
|
|
8893
7608
|
display_tz=display_tz_str,
|
|
8894
7609
|
version=_share_resolve_version(),
|
|
8895
7610
|
theme=args.theme,
|
|
@@ -10137,7 +8852,7 @@ def doctor_gather_state(
|
|
|
10137
8852
|
)
|
|
10138
8853
|
|
|
10139
8854
|
# ── Meta ─────────────────────────────────────────────────────────
|
|
10140
|
-
cctally_version_tuple =
|
|
8855
|
+
cctally_version_tuple = _lib_changelog._read_latest_changelog_version()
|
|
10141
8856
|
cctally_version = (
|
|
10142
8857
|
cctally_version_tuple[0] if cctally_version_tuple else "unknown"
|
|
10143
8858
|
)
|
|
@@ -11857,90 +10572,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
11857
10572
|
)
|
|
11858
10573
|
doctor_p.set_defaults(func=cmd_doctor)
|
|
11859
10574
|
|
|
11860
|
-
#
|
|
11861
|
-
|
|
11862
|
-
|
|
11863
|
-
help="Stamp CHANGELOG, cut SemVer tag, mirror, and create GitHub Release",
|
|
11864
|
-
formatter_class=CLIHelpFormatter,
|
|
11865
|
-
description=textwrap.dedent(
|
|
11866
|
-
"""\
|
|
11867
|
-
Issue #24 release automation. Four idempotent phases:
|
|
11868
|
-
1. Stamp CHANGELOG.md (move [Unreleased] entries under [X.Y.Z]).
|
|
11869
|
-
2. Annotated tag vX.Y.Z + push --follow-tags.
|
|
11870
|
-
3. Mirror push (replay private → push public branch + tag).
|
|
11871
|
-
4. GitHub Release create on the public mirror.
|
|
11872
|
-
|
|
11873
|
-
See docs/commands/release.md for full reference.
|
|
11874
|
-
"""
|
|
11875
|
-
),
|
|
11876
|
-
)
|
|
11877
|
-
p_release.add_argument(
|
|
11878
|
-
"kind",
|
|
11879
|
-
nargs="?",
|
|
11880
|
-
choices=["patch", "minor", "major", "prerelease", "finalize"],
|
|
11881
|
-
help="Bump kind (omit only with --resume)",
|
|
11882
|
-
)
|
|
11883
|
-
p_release.add_argument(
|
|
11884
|
-
"--resume",
|
|
11885
|
-
action="store_true",
|
|
11886
|
-
help="Continue an in-progress release (infers vX.Y.Z from latest CHANGELOG header)",
|
|
11887
|
-
)
|
|
11888
|
-
p_release.add_argument(
|
|
11889
|
-
"--dry-run",
|
|
11890
|
-
action="store_true",
|
|
11891
|
-
help="Print phase plan with diff/tag/release; mutate nothing; exit 0",
|
|
11892
|
-
)
|
|
11893
|
-
p_release.add_argument(
|
|
11894
|
-
"--no-publish",
|
|
11895
|
-
action="store_true",
|
|
11896
|
-
help="Stop after Phase 2 (tag); skip Phases 3-6 (mirror, gh release, npm, brew)",
|
|
11897
|
-
)
|
|
11898
|
-
p_release.add_argument(
|
|
11899
|
-
"--prerelease-id",
|
|
11900
|
-
default="rc",
|
|
11901
|
-
help="Override 'rc' for prerelease bumps (default 'rc')",
|
|
11902
|
-
)
|
|
11903
|
-
p_release.add_argument(
|
|
11904
|
-
"--bump",
|
|
11905
|
-
choices=["patch", "minor", "major"],
|
|
11906
|
-
help="REQUIRED with `prerelease` when current is stable; REFUSED when current is prerelease",
|
|
11907
|
-
)
|
|
11908
|
-
p_release.add_argument(
|
|
11909
|
-
"--remote",
|
|
11910
|
-
default="origin",
|
|
11911
|
-
help="Default 'origin' (private remote)",
|
|
11912
|
-
)
|
|
11913
|
-
p_release.add_argument(
|
|
11914
|
-
"--allow-branch",
|
|
11915
|
-
default=None,
|
|
11916
|
-
help="Override the on-main refusal (escape hatch)",
|
|
11917
|
-
)
|
|
11918
|
-
p_release.add_argument(
|
|
11919
|
-
"--public-clone",
|
|
11920
|
-
default=None,
|
|
11921
|
-
help="Override public-clone discovery (default: git config 'release.publicClone' or marker file)",
|
|
11922
|
-
)
|
|
11923
|
-
p_release.add_argument(
|
|
11924
|
-
"--skip-npm",
|
|
11925
|
-
action="store_true",
|
|
11926
|
-
help="Skip Phase 5 (npm publish). Idempotent: re-running --resume without it picks the channel back up.",
|
|
11927
|
-
)
|
|
11928
|
-
p_release.add_argument(
|
|
11929
|
-
"--brew-clone",
|
|
11930
|
-
default=None,
|
|
11931
|
-
help="Override brew tap discovery (default: git config 'release.brewClone' or marker file)",
|
|
11932
|
-
)
|
|
11933
|
-
p_release.add_argument(
|
|
11934
|
-
"--skip-brew",
|
|
11935
|
-
action="store_true",
|
|
11936
|
-
help="Skip Phase 6 (brew formula bump). Idempotent: re-running --resume without it picks the channel back up.",
|
|
11937
|
-
)
|
|
11938
|
-
p_release.add_argument(
|
|
11939
|
-
"--allow-formula-downgrade",
|
|
11940
|
-
action="store_true",
|
|
11941
|
-
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.",
|
|
11942
|
-
)
|
|
11943
|
-
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.
|
|
11944
10578
|
|
|
11945
10579
|
# ---- hook-tick (internal — hidden from --help, see onboarding spec §3) ----
|
|
11946
10580
|
ht = sub.add_parser(
|
|
@@ -12287,12 +10921,13 @@ def _share_history_recipe_id() -> str:
|
|
|
12287
10921
|
|
|
12288
10922
|
|
|
12289
10923
|
def _share_resolve_version() -> str:
|
|
12290
|
-
"""Source from CHANGELOG via the
|
|
10924
|
+
"""Source from CHANGELOG via the public helper. Empty string if unset.
|
|
12291
10925
|
|
|
12292
|
-
`
|
|
12293
|
-
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.
|
|
12294
10929
|
"""
|
|
12295
|
-
info =
|
|
10930
|
+
info = _lib_changelog._read_latest_changelog_version()
|
|
12296
10931
|
return info[0] if info else ""
|
|
12297
10932
|
|
|
12298
10933
|
|
|
@@ -13211,7 +11846,7 @@ def _build_project_snapshot(
|
|
|
13211
11846
|
|
|
13212
11847
|
|
|
13213
11848
|
def _build_five_hour_blocks_snapshot(
|
|
13214
|
-
|
|
11849
|
+
view: "BlocksView",
|
|
13215
11850
|
*,
|
|
13216
11851
|
period_start: dt.datetime,
|
|
13217
11852
|
period_end: dt.datetime,
|
|
@@ -13223,13 +11858,18 @@ def _build_five_hour_blocks_snapshot(
|
|
|
13223
11858
|
) -> "ShareSnapshot":
|
|
13224
11859
|
"""Build a ShareSnapshot for `cctally five-hour-blocks`.
|
|
13225
11860
|
|
|
13226
|
-
`
|
|
13227
|
-
|
|
13228
|
-
|
|
13229
|
-
|
|
13230
|
-
|
|
13231
|
-
|
|
13232
|
-
|
|
11861
|
+
`view` is the ``BlocksView`` produced by
|
|
11862
|
+
``build_blocks_view_from_table_rows`` (issue #56). The
|
|
11863
|
+
API-anchored block dicts (sqlite Row → dict with the
|
|
11864
|
+
``__is_active`` / ``__credits`` side-channels attached) live on
|
|
11865
|
+
``view.aggregated``; reset-aware totals come from
|
|
11866
|
+
``view.total_cost_usd`` so the share footer reads from the typed
|
|
11867
|
+
single source rather than re-summing inline. Schema fields used
|
|
11868
|
+
from each dict: ``block_start_at`` (ISO timestamp),
|
|
11869
|
+
``total_cost_usd``, ``final_five_hour_percent``,
|
|
11870
|
+
``crossed_seven_day_reset`` (0/1 int),
|
|
11871
|
+
``seven_day_pct_at_block_start``, ``seven_day_pct_at_block_end``,
|
|
11872
|
+
plus the synthetic ``__is_active`` flag.
|
|
13233
11873
|
|
|
13234
11874
|
Deviations from the plan sketch (which assumed dict rows with keys
|
|
13235
11875
|
`block_start` / `cost_usd` / `used_pct_5h` / `top_model` /
|
|
@@ -13261,11 +11901,12 @@ def _build_five_hour_blocks_snapshot(
|
|
|
13261
11901
|
the builder owns the canonical subtitle shape — no post-build
|
|
13262
11902
|
re-stamp at the gate site.
|
|
13263
11903
|
|
|
13264
|
-
Caller MUST pass
|
|
13265
|
-
|
|
13266
|
-
BarChart bars line up
|
|
13267
|
-
|
|
13268
|
-
|
|
11904
|
+
Caller MUST pass a view whose ``aggregated`` block dicts are
|
|
11905
|
+
already in the desired chronological order (cmd_five_hour_blocks
|
|
11906
|
+
pulls newest-first; we reverse here so the BarChart bars line up
|
|
11907
|
+
oldest→newest left-to-right). Tabular row order in the snapshot is
|
|
11908
|
+
irrelevant because the snapshot is what gets rendered (the gate
|
|
11909
|
+
site short-circuits the table renderer).
|
|
13269
11910
|
"""
|
|
13270
11911
|
_lib_share = _share_load_lib()
|
|
13271
11912
|
columns = (
|
|
@@ -13278,9 +11919,11 @@ def _build_five_hour_blocks_snapshot(
|
|
|
13278
11919
|
_lib_share.ColumnSpec(key="cross_reset", label="Reset",
|
|
13279
11920
|
align="left"),
|
|
13280
11921
|
)
|
|
13281
|
-
#
|
|
13282
|
-
#
|
|
13283
|
-
# chart order so consumer
|
|
11922
|
+
# `view.aggregated` carries the newest-first DESC block dicts the
|
|
11923
|
+
# caller built from the SELECT. Reverse so BarChart x-axis runs
|
|
11924
|
+
# oldest→newest; table-row order tracks chart order so consumer
|
|
11925
|
+
# expectations align.
|
|
11926
|
+
rows = list(view.aggregated)
|
|
13284
11927
|
chrono_rows = list(reversed(rows))
|
|
13285
11928
|
snap_rows: list = []
|
|
13286
11929
|
chart_pts: list = []
|
|
@@ -13336,7 +11979,11 @@ def _build_five_hour_blocks_snapshot(
|
|
|
13336
11979
|
_lib_share.BarChart(points=tuple(chart_pts), y_label="$")
|
|
13337
11980
|
if chart_pts else None
|
|
13338
11981
|
)
|
|
13339
|
-
|
|
11982
|
+
# Reset-aware total comes from the BlocksView (issue #56); avg
|
|
11983
|
+
# divides by `chart_pts` count so the share footer "Sum" totalled
|
|
11984
|
+
# and the per-block `chart_pts` cost values share a single source-
|
|
11985
|
+
# of-truth at `view.total_cost_usd`.
|
|
11986
|
+
sum_cost = view.total_cost_usd
|
|
13340
11987
|
avg_cost = (sum_cost / len(chart_pts)) if chart_pts else 0.0
|
|
13341
11988
|
crossed_count = sum(
|
|
13342
11989
|
1 for r in chrono_rows if bool(r.get("crossed_seven_day_reset"))
|
|
@@ -13844,8 +12491,6 @@ cmd_tui = _cctally_tui.cmd_tui
|
|
|
13844
12491
|
_tui_render_once = _cctally_tui._tui_render_once
|
|
13845
12492
|
|
|
13846
12493
|
|
|
13847
|
-
|
|
13848
|
-
|
|
13849
12494
|
def main(argv: list[str] | None = None) -> int:
|
|
13850
12495
|
_migrate_legacy_data_dir()
|
|
13851
12496
|
parser = build_parser()
|
|
@@ -13854,7 +12499,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
13854
12499
|
# release header (issue #24); handle BEFORE subcommand dispatch so it
|
|
13855
12500
|
# works without a subcommand (`cctally --version`).
|
|
13856
12501
|
if getattr(args, "version", False):
|
|
13857
|
-
v =
|
|
12502
|
+
v = _lib_changelog._read_latest_changelog_version()
|
|
13858
12503
|
if v is None:
|
|
13859
12504
|
print("cctally unknown")
|
|
13860
12505
|
else:
|