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_release.py
DELETED
|
@@ -1,751 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""cctally — release-automation phases extracted from bin/cctally.
|
|
3
|
-
|
|
4
|
-
This module hosts symbols that compose the six-phase `cctally release`
|
|
5
|
-
flow but are NOT directly monkeypatched by tests. The remaining release
|
|
6
|
-
helpers (the per-phase `_done`/`_run_phase_*` predicates, preflight gates,
|
|
7
|
-
clone discoverers, etc.) live in bin/cctally so test monkeypatches via
|
|
8
|
-
`monkeypatch.setattr(cctally, "X", ...)` keep working unchanged.
|
|
9
|
-
|
|
10
|
-
Loaded lazily by bin/cctally via a PEP 562 `__getattr__` registry (wired
|
|
11
|
-
in Bundle 4 / Commit #2). Until then the file exists on disk as the
|
|
12
|
-
out-of-band target for bin/_cctally_release.py.
|
|
13
|
-
|
|
14
|
-
References to symbols that stay in bin/cctally (path constants, regex
|
|
15
|
-
compiles, the audit-driven STAYING set) are routed through
|
|
16
|
-
`cctally.<name>`, taking advantage of the sys.modules['cctally']
|
|
17
|
-
registration established by bin/cctally's `__main__` setdefault and by
|
|
18
|
-
tests/conftest.py's exec_module bridge (spec §5.1, §6.0a).
|
|
19
|
-
|
|
20
|
-
Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
21
|
-
Plan: docs/superpowers/plans/2026-05-13-bin-cctally-split.md (Bundle 3 / Task 16)
|
|
22
|
-
"""
|
|
23
|
-
|
|
24
|
-
from __future__ import annotations
|
|
25
|
-
|
|
26
|
-
import argparse
|
|
27
|
-
import datetime as dt
|
|
28
|
-
import hashlib
|
|
29
|
-
import json
|
|
30
|
-
import os
|
|
31
|
-
import pathlib
|
|
32
|
-
import re
|
|
33
|
-
import subprocess
|
|
34
|
-
import sys
|
|
35
|
-
import urllib.request
|
|
36
|
-
|
|
37
|
-
from _lib_semver import (
|
|
38
|
-
_SEMVER_NUM,
|
|
39
|
-
_release_compute_next_version,
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def _cctally():
|
|
44
|
-
"""Resolve the current `cctally` module at call-time.
|
|
45
|
-
|
|
46
|
-
We DON'T `import cctally` at module top: the `import` statement
|
|
47
|
-
binds `cctally` in `_cctally_release.__dict__` at the moment of
|
|
48
|
-
first import. Tests that load a fresh `bin/cctally` namespace
|
|
49
|
-
(e.g. via load_script() in tests/conftest.py, or per-test-file
|
|
50
|
-
SourceFileLoader registration) reassign `sys.modules["cctally"]`.
|
|
51
|
-
Anything bound at import time stays pinned to the OLD module, so a
|
|
52
|
-
`monkeypatch.setattr(cctally, "CHANGELOG_PATH", tmp)` in a NEW
|
|
53
|
-
cctally instance doesn't propagate to the back-references here —
|
|
54
|
-
and stamps leak to the on-disk repo.
|
|
55
|
-
|
|
56
|
-
Resolving via `sys.modules["cctally"]` on every call means the
|
|
57
|
-
back-reference always tracks the *current* test session's cctally
|
|
58
|
-
instance. One dict lookup per access; negligible vs. the test
|
|
59
|
-
isolation it buys.
|
|
60
|
-
|
|
61
|
-
Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §5.5
|
|
62
|
-
"""
|
|
63
|
-
return sys.modules["cctally"]
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def _package_json_path() -> pathlib.Path:
|
|
67
|
-
"""Resolve package.json's path next to CHANGELOG.md.
|
|
68
|
-
|
|
69
|
-
Indirected through CHANGELOG_PATH so that tests monkeypatching
|
|
70
|
-
CHANGELOG_PATH (to redirect into a fixture repo) also redirect
|
|
71
|
-
package.json — Phase 1 co-stamp and any read-side caller (e.g.
|
|
72
|
-
the Phase 1 done-check) stay in lockstep without a separate
|
|
73
|
-
monkeypatch surface.
|
|
74
|
-
"""
|
|
75
|
-
return _cctally().CHANGELOG_PATH.parent / "package.json"
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def _homebrew_template_path() -> pathlib.Path:
|
|
79
|
-
"""Resolve the Homebrew formula template's path under the repo root.
|
|
80
|
-
|
|
81
|
-
Indirected through CHANGELOG_PATH for the same reason as
|
|
82
|
-
:func:`_package_json_path`: Phase 6 fixture tests monkeypatch
|
|
83
|
-
CHANGELOG_PATH to redirect into a fixture repo, and the template
|
|
84
|
-
must follow without a separate monkeypatch surface.
|
|
85
|
-
"""
|
|
86
|
-
return _cctally().CHANGELOG_PATH.parent / "homebrew" / "cctally.rb.template"
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
_FORMULA_VERSION_RE = re.compile(
|
|
90
|
-
rf'/v({_SEMVER_NUM}\.{_SEMVER_NUM}\.{_SEMVER_NUM}'
|
|
91
|
-
rf'(?:-[a-zA-Z][a-zA-Z0-9-]*\.{_SEMVER_NUM})?)\.tar\.gz'
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def _release_extract_formula_version(text: str) -> str | None:
|
|
96
|
-
"""Extract the SemVer string from a homebrew formula's archive URL.
|
|
97
|
-
|
|
98
|
-
Returns the matched version (e.g. `"1.3.0"`, `"1.0.0-rc.1"`) or
|
|
99
|
-
``None`` if no `/vX.Y.Z[.tar.gz]` substring is found. Used by Phase 6's
|
|
100
|
-
monotonic-version gate (issue #30) — the gate refuses to write a lower
|
|
101
|
-
version on top of a higher one. A formula that does not match is
|
|
102
|
-
treated as unversioned (gate allows the write).
|
|
103
|
-
"""
|
|
104
|
-
m = _FORMULA_VERSION_RE.search(text)
|
|
105
|
-
return m.group(1) if m else None
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def _release_brew_archive_url(version: str) -> str:
|
|
109
|
-
"""Return the GitHub auto-archive URL for a version tag.
|
|
110
|
-
|
|
111
|
-
Honors hidden env hook ``CCTALLY_RELEASE_BREW_ARCHIVE_URL`` for
|
|
112
|
-
fixture tests (the value is used verbatim; ``{version}`` placeholder
|
|
113
|
-
is substituted if present). Mirrors the ``CCTALLY_RELEASE_DATE_UTC``
|
|
114
|
-
and ``CCTALLY_AS_OF`` precedents — env-only, not in --help.
|
|
115
|
-
"""
|
|
116
|
-
override = os.environ.get("CCTALLY_RELEASE_BREW_ARCHIVE_URL")
|
|
117
|
-
if override:
|
|
118
|
-
return override.replace("{version}", version)
|
|
119
|
-
return (
|
|
120
|
-
f"https://github.com/{_cctally().PUBLIC_REPO}/archive/refs/tags/v{version}.tar.gz"
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def _release_parse_changelog(text: str) -> dict:
|
|
125
|
-
"""Parse CHANGELOG.md into {preamble, sections[]}.
|
|
126
|
-
|
|
127
|
-
Each section: {heading, subsections[]}
|
|
128
|
-
Each subsection: {heading, bullets[]}
|
|
129
|
-
Bullets preserve continuation lines (multi-line bullet blocks stay intact).
|
|
130
|
-
Bullet markers recognized: "- " and "* " only (not "+ ").
|
|
131
|
-
Code-fenced blocks suppress heading detection; fences recognized: ``` only (not ~~~).
|
|
132
|
-
"""
|
|
133
|
-
lines = text.splitlines(keepends=False)
|
|
134
|
-
preamble: list[str] = []
|
|
135
|
-
sections: list[dict] = []
|
|
136
|
-
cur_section: dict | None = None
|
|
137
|
-
cur_subsection: dict | None = None
|
|
138
|
-
cur_bullet_lines: list[str] = []
|
|
139
|
-
in_fence = False
|
|
140
|
-
|
|
141
|
-
def _flush_bullet():
|
|
142
|
-
nonlocal cur_bullet_lines
|
|
143
|
-
if cur_bullet_lines and cur_subsection is not None:
|
|
144
|
-
cur_subsection["bullets"].append("\n".join(cur_bullet_lines))
|
|
145
|
-
cur_bullet_lines = []
|
|
146
|
-
|
|
147
|
-
for line in lines:
|
|
148
|
-
stripped = line.lstrip()
|
|
149
|
-
if stripped.startswith("```"):
|
|
150
|
-
in_fence = not in_fence
|
|
151
|
-
cur_bullet_lines.append(line) if cur_bullet_lines else None
|
|
152
|
-
continue
|
|
153
|
-
|
|
154
|
-
if not in_fence and line.startswith("## "):
|
|
155
|
-
_flush_bullet()
|
|
156
|
-
cur_subsection = None
|
|
157
|
-
cur_section = {"heading": line, "subsections": []}
|
|
158
|
-
sections.append(cur_section)
|
|
159
|
-
continue
|
|
160
|
-
|
|
161
|
-
if not in_fence and line.startswith("### ") and cur_section is not None:
|
|
162
|
-
_flush_bullet()
|
|
163
|
-
cur_subsection = {"heading": line, "bullets": []}
|
|
164
|
-
cur_section["subsections"].append(cur_subsection)
|
|
165
|
-
continue
|
|
166
|
-
|
|
167
|
-
if cur_section is None:
|
|
168
|
-
preamble.append(line)
|
|
169
|
-
continue
|
|
170
|
-
|
|
171
|
-
# Bullet detection: "- " or "* " at start of line (not in fence)
|
|
172
|
-
if not in_fence and (line.startswith("- ") or line.startswith("* ")):
|
|
173
|
-
_flush_bullet()
|
|
174
|
-
cur_bullet_lines = [line]
|
|
175
|
-
continue
|
|
176
|
-
|
|
177
|
-
# Continuation of current bullet (indented or empty within block)
|
|
178
|
-
if cur_bullet_lines and (line.startswith(" ") or line.startswith("\t") or in_fence):
|
|
179
|
-
cur_bullet_lines.append(line)
|
|
180
|
-
continue
|
|
181
|
-
|
|
182
|
-
# Blank line ends the current bullet
|
|
183
|
-
if line.strip() == "":
|
|
184
|
-
_flush_bullet()
|
|
185
|
-
continue
|
|
186
|
-
|
|
187
|
-
_flush_bullet()
|
|
188
|
-
return {"preamble": "\n".join(preamble), "sections": sections}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def _release_canonical_body(section: dict) -> str:
|
|
192
|
-
"""Serialize a parsed section's subsections + bullets to canonical body string.
|
|
193
|
-
|
|
194
|
-
Used identically by:
|
|
195
|
-
- public block of stamp commit
|
|
196
|
-
- tag annotation body
|
|
197
|
-
- GH Release --notes-file content
|
|
198
|
-
|
|
199
|
-
Format (spec §6.4): subsection headers + bullets, blank line between
|
|
200
|
-
subsections, no trailing newline.
|
|
201
|
-
"""
|
|
202
|
-
blocks: list[str] = []
|
|
203
|
-
for sub in section["subsections"]:
|
|
204
|
-
if not sub["bullets"]:
|
|
205
|
-
continue
|
|
206
|
-
block_lines = [sub["heading"], *sub["bullets"]]
|
|
207
|
-
blocks.append("\n".join(block_lines))
|
|
208
|
-
return "\n\n".join(blocks)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
def _release_stamp_changelog(text: str, version: str, today_utc: str) -> tuple[str, str]:
|
|
212
|
-
"""Stamp [Unreleased] entries into a new [version] section.
|
|
213
|
-
|
|
214
|
-
Returns (new_changelog_text, canonical_body_string).
|
|
215
|
-
Raises ValueError on empty [Unreleased].
|
|
216
|
-
"""
|
|
217
|
-
parsed = _release_parse_changelog(text)
|
|
218
|
-
sections = parsed["sections"]
|
|
219
|
-
if not sections or sections[0]["heading"].strip() != "## [Unreleased]":
|
|
220
|
-
raise ValueError("CHANGELOG.md must start with '## [Unreleased]' section")
|
|
221
|
-
unreleased = sections[0]
|
|
222
|
-
non_empty = [s for s in unreleased["subsections"] if s["bullets"]]
|
|
223
|
-
if not non_empty:
|
|
224
|
-
raise ValueError("[Unreleased] is empty; nothing to release")
|
|
225
|
-
|
|
226
|
-
# Build new section as a parsed-section-shaped dict for canonical body extraction
|
|
227
|
-
new_section = {
|
|
228
|
-
"heading": f"## [{version}] - {today_utc}",
|
|
229
|
-
"subsections": [
|
|
230
|
-
{"heading": s["heading"], "bullets": list(s["bullets"])}
|
|
231
|
-
for s in non_empty
|
|
232
|
-
],
|
|
233
|
-
}
|
|
234
|
-
body = _release_canonical_body(new_section)
|
|
235
|
-
|
|
236
|
-
# Reset Unreleased: drop subsections, keep only heading
|
|
237
|
-
unreleased["subsections"] = []
|
|
238
|
-
|
|
239
|
-
# Insert new section after Unreleased
|
|
240
|
-
sections.insert(1, new_section)
|
|
241
|
-
|
|
242
|
-
# Serialize. rstrip trailing newlines from preamble so re-stamping is
|
|
243
|
-
# idempotent on the preamble→first-section gap: splitlines() leaves a
|
|
244
|
-
# trailing empty-string element when the preamble ends in a blank line,
|
|
245
|
-
# which combined with the explicit "" separator below would add one
|
|
246
|
-
# extra blank line on every round-trip.
|
|
247
|
-
out_parts = [parsed["preamble"].rstrip("\n"), ""]
|
|
248
|
-
for sec in sections:
|
|
249
|
-
out_parts.append(sec["heading"])
|
|
250
|
-
out_parts.append("")
|
|
251
|
-
for sub in sec["subsections"]:
|
|
252
|
-
out_parts.append(sub["heading"])
|
|
253
|
-
out_parts.extend(sub["bullets"])
|
|
254
|
-
out_parts.append("")
|
|
255
|
-
new_text = "\n".join(out_parts).rstrip() + "\n"
|
|
256
|
-
return (new_text, body)
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
def _release_stamp_package_json(text: str, version: str) -> str:
|
|
260
|
-
"""Rewrite package.json's `version` field to `version`.
|
|
261
|
-
|
|
262
|
-
Idempotent: stamping the same version twice yields byte-identical
|
|
263
|
-
output. Preserves two-space indent + trailing newline. Raises
|
|
264
|
-
ValueError if the file is malformed JSON or has no `version` field.
|
|
265
|
-
"""
|
|
266
|
-
try:
|
|
267
|
-
data = json.loads(text)
|
|
268
|
-
except json.JSONDecodeError as exc:
|
|
269
|
-
raise ValueError(f"package.json: malformed JSON ({exc.msg})") from exc
|
|
270
|
-
if "version" not in data:
|
|
271
|
-
raise ValueError("package.json: missing 'version' field")
|
|
272
|
-
data["version"] = version
|
|
273
|
-
out = json.dumps(data, indent=2, ensure_ascii=False)
|
|
274
|
-
if not out.endswith("\n"):
|
|
275
|
-
out += "\n"
|
|
276
|
-
return out
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
def _release_preflight_tag_clobber(version: str, remote: str) -> None:
|
|
280
|
-
"""Refuse if vX.Y.Z tag already exists locally or on remote (spec §10.1 step 5)."""
|
|
281
|
-
tag = f"v{version}"
|
|
282
|
-
local_tags = (
|
|
283
|
-
subprocess.check_output(
|
|
284
|
-
["git", "tag", "-l", tag],
|
|
285
|
-
text=True,
|
|
286
|
-
cwd=str(_cctally().CHANGELOG_PATH.parent),
|
|
287
|
-
)
|
|
288
|
-
.strip()
|
|
289
|
-
.splitlines()
|
|
290
|
-
)
|
|
291
|
-
if tag in local_tags:
|
|
292
|
-
print(
|
|
293
|
-
f"release: tag {tag} already exists locally; this would clobber",
|
|
294
|
-
file=sys.stderr,
|
|
295
|
-
)
|
|
296
|
-
sys.exit(2)
|
|
297
|
-
try:
|
|
298
|
-
out = subprocess.check_output(
|
|
299
|
-
["git", "ls-remote", "--tags", remote, f"refs/tags/{tag}"],
|
|
300
|
-
text=True,
|
|
301
|
-
stderr=subprocess.DEVNULL,
|
|
302
|
-
cwd=str(_cctally().CHANGELOG_PATH.parent),
|
|
303
|
-
).strip()
|
|
304
|
-
if out:
|
|
305
|
-
print(
|
|
306
|
-
f"release: tag {tag} already exists on {remote}; this would clobber",
|
|
307
|
-
file=sys.stderr,
|
|
308
|
-
)
|
|
309
|
-
sys.exit(2)
|
|
310
|
-
except subprocess.CalledProcessError:
|
|
311
|
-
# Remote unreachable; the up-to-date preflight would have caught this
|
|
312
|
-
# in the non-dry-run path. Don't refuse on remote-side ambiguity.
|
|
313
|
-
pass
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
def _release_compute_brew_sha256(version: str) -> str:
|
|
317
|
-
"""Download the GH archive tarball for ``version`` and return sha256.
|
|
318
|
-
|
|
319
|
-
URL is resolved through :func:`_release_brew_archive_url` (Task 0),
|
|
320
|
-
which honors the ``CCTALLY_RELEASE_BREW_ARCHIVE_URL`` env hook for
|
|
321
|
-
fixture tests. Mirrors the ``CCTALLY_RELEASE_DATE_UTC`` precedent —
|
|
322
|
-
env-only, not surfaced in --help.
|
|
323
|
-
|
|
324
|
-
The 30-second timeout is sized for the GH auto-archive endpoint —
|
|
325
|
-
its initial response can lag a few seconds while the tarball is
|
|
326
|
-
materialized server-side. Failures bubble up as ``urllib.error``
|
|
327
|
-
or ``socket.timeout`` to the caller (Phase 6), where they surface
|
|
328
|
-
as a non-zero return that ``--resume`` can retry.
|
|
329
|
-
"""
|
|
330
|
-
url = _release_brew_archive_url(version)
|
|
331
|
-
req = urllib.request.Request(
|
|
332
|
-
url, headers={"User-Agent": "cctally-release"}
|
|
333
|
-
)
|
|
334
|
-
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
335
|
-
data = resp.read()
|
|
336
|
-
return hashlib.sha256(data).hexdigest()
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
def _release_print_gh_fallback(version: str, body: str) -> None:
|
|
340
|
-
"""Print a copy-pasteable ``gh release create`` command (spec §9.2).
|
|
341
|
-
|
|
342
|
-
Used when the auth probe fails. Prints two commands: first writes the
|
|
343
|
-
notes body to ``/tmp/release-notes-vX.Y.Z.md`` via heredoc, then
|
|
344
|
-
invokes ``gh release create --notes-file <that path>``. Splitting the
|
|
345
|
-
body off into a tmpfile (vs. the original ``<(cat <<'EOF' ... EOF)``
|
|
346
|
-
process-substitution heredoc) keeps the operator from having to escape
|
|
347
|
-
backticks / dollar signs and makes diffing the notes easy if the
|
|
348
|
-
paste doesn't take.
|
|
349
|
-
|
|
350
|
-
The heredoc terminator is randomized per invocation
|
|
351
|
-
(``CCTALLY_EOF_<pid>``) so a body that happens to contain a bare
|
|
352
|
-
``EOF`` line — common in CHANGELOG entries that quote shell snippets
|
|
353
|
-
— does NOT prematurely terminate the heredoc. If the operator's body
|
|
354
|
-
somehow contains a line matching the randomized terminator exactly,
|
|
355
|
-
they need to either edit the body or pick a different terminator
|
|
356
|
-
before pasting; we surface that constraint in the printout.
|
|
357
|
-
|
|
358
|
-
Returning the operator to a known-good state means ``release
|
|
359
|
-
v{version} published except GH Release; phase 4 awaits manual
|
|
360
|
-
completion`` — phases 1-3 already landed, so the release IS published
|
|
361
|
-
from the public mirror's perspective; only the GitHub Releases UI
|
|
362
|
-
surface is incomplete.
|
|
363
|
-
"""
|
|
364
|
-
is_prerelease = "-" in version
|
|
365
|
-
terminator = f"CCTALLY_EOF_{os.getpid()}"
|
|
366
|
-
notes_path = f"/tmp/release-notes-v{version}.md"
|
|
367
|
-
print(f"release: gh release ⚠ skipped (no auth for {_cctally().PUBLIC_REPO})")
|
|
368
|
-
print()
|
|
369
|
-
print("Run this manually to publish the GitHub Release:")
|
|
370
|
-
print()
|
|
371
|
-
print(f" cat > {notes_path} <<'{terminator}'")
|
|
372
|
-
print(body)
|
|
373
|
-
print(f" {terminator}")
|
|
374
|
-
print()
|
|
375
|
-
print(f" gh release create v{version} \\")
|
|
376
|
-
print(f" --repo {_cctally().PUBLIC_REPO} \\")
|
|
377
|
-
print(f" --title v{version} \\")
|
|
378
|
-
if is_prerelease:
|
|
379
|
-
print(f" --notes-file {notes_path} \\")
|
|
380
|
-
print(" --prerelease")
|
|
381
|
-
else:
|
|
382
|
-
print(f" --notes-file {notes_path}")
|
|
383
|
-
print()
|
|
384
|
-
print(
|
|
385
|
-
f"(If your CHANGELOG body contains a line that exactly matches the "
|
|
386
|
-
f"terminator '{terminator}', edit the body or change the terminator "
|
|
387
|
-
"before pasting.)"
|
|
388
|
-
)
|
|
389
|
-
print()
|
|
390
|
-
print(
|
|
391
|
-
"(or after running 'gh auth login', re-run "
|
|
392
|
-
"'cctally release --resume' to complete this phase.)"
|
|
393
|
-
)
|
|
394
|
-
print()
|
|
395
|
-
print(
|
|
396
|
-
f"release v{version} published except GH Release; "
|
|
397
|
-
"phase 4 awaits manual completion"
|
|
398
|
-
)
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
_RELEASE_NPM_POLL_TIMEOUT_S_DEFAULT = 300.0
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
_RELEASE_NPM_POLL_INTERVAL_S_DEFAULT = 10.0
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
def _release_npm_poll_timing() -> tuple[float, float]:
|
|
408
|
-
"""Return (timeout_s, interval_s) honoring env-hook overrides.
|
|
409
|
-
|
|
410
|
-
Hidden env hooks (mirrors ``CCTALLY_RELEASE_DATE_UTC``):
|
|
411
|
-
- CCTALLY_RELEASE_NPM_POLL_TIMEOUT_S
|
|
412
|
-
- CCTALLY_RELEASE_NPM_POLL_INTERVAL_S
|
|
413
|
-
Used by the harness (and pytest) to make Phase 5 fixtures deterministic.
|
|
414
|
-
Not in --help.
|
|
415
|
-
"""
|
|
416
|
-
def _f(name: str, default: float) -> float:
|
|
417
|
-
try:
|
|
418
|
-
return float(os.environ[name])
|
|
419
|
-
except (KeyError, ValueError):
|
|
420
|
-
return default
|
|
421
|
-
return (
|
|
422
|
-
_f("CCTALLY_RELEASE_NPM_POLL_TIMEOUT_S", _RELEASE_NPM_POLL_TIMEOUT_S_DEFAULT),
|
|
423
|
-
_f("CCTALLY_RELEASE_NPM_POLL_INTERVAL_S", _RELEASE_NPM_POLL_INTERVAL_S_DEFAULT),
|
|
424
|
-
)
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
def cmd_release(args: argparse.Namespace) -> int:
|
|
428
|
-
"""Issue #24 — release automation entry point.
|
|
429
|
-
|
|
430
|
-
Phases (each idempotent; subsequent tasks land the real implementations):
|
|
431
|
-
1. Stamp CHANGELOG.md.
|
|
432
|
-
2. Annotated tag, push --follow-tags.
|
|
433
|
-
3. Mirror push (replay → push branch → push tag).
|
|
434
|
-
4. GitHub Release create.
|
|
435
|
-
|
|
436
|
-
--resume infers vX.Y.Z from the latest CHANGELOG header.
|
|
437
|
-
"""
|
|
438
|
-
# Args validation (spec §10.1 step 1).
|
|
439
|
-
if args.resume and (args.kind or args.bump):
|
|
440
|
-
print(
|
|
441
|
-
"release: --resume is mutually exclusive with kind / --bump",
|
|
442
|
-
file=sys.stderr,
|
|
443
|
-
)
|
|
444
|
-
return 2
|
|
445
|
-
if not args.resume and not args.kind:
|
|
446
|
-
print(
|
|
447
|
-
"release: missing kind (patch|minor|major|prerelease|finalize)",
|
|
448
|
-
file=sys.stderr,
|
|
449
|
-
)
|
|
450
|
-
return 2
|
|
451
|
-
if args.bump and args.kind != "prerelease":
|
|
452
|
-
print("release: --bump valid only with `prerelease`", file=sys.stderr)
|
|
453
|
-
return 2
|
|
454
|
-
|
|
455
|
-
# Preflight (spec §10.1).
|
|
456
|
-
_cctally()._release_preflight_branch(args.allow_branch)
|
|
457
|
-
if not args.dry_run:
|
|
458
|
-
_cctally()._release_preflight_clean_tree()
|
|
459
|
-
if not args.dry_run and not args.resume:
|
|
460
|
-
_cctally()._release_preflight_up_to_date(args.remote)
|
|
461
|
-
|
|
462
|
-
# Determine target version.
|
|
463
|
-
current = _cctally()._release_read_latest_release_version()
|
|
464
|
-
current_v = current[0] if current else None
|
|
465
|
-
if args.resume:
|
|
466
|
-
if current_v is None:
|
|
467
|
-
print(
|
|
468
|
-
"release: no in-progress release found; "
|
|
469
|
-
"CHANGELOG has no release header",
|
|
470
|
-
file=sys.stderr,
|
|
471
|
-
)
|
|
472
|
-
return 2
|
|
473
|
-
next_v = current_v
|
|
474
|
-
else:
|
|
475
|
-
try:
|
|
476
|
-
next_v = _release_compute_next_version(
|
|
477
|
-
current_v, args.kind, args.bump, args.prerelease_id
|
|
478
|
-
)
|
|
479
|
-
except ValueError as e:
|
|
480
|
-
# Pass through the helper's wording verbatim — tests in Tasks 2+3
|
|
481
|
-
# match the prefix substring; we're a transparent renderer here.
|
|
482
|
-
print(f"release: {e}", file=sys.stderr)
|
|
483
|
-
return 2
|
|
484
|
-
|
|
485
|
-
if not args.resume:
|
|
486
|
-
_release_preflight_tag_clobber(next_v, args.remote)
|
|
487
|
-
|
|
488
|
-
# Dry-run path.
|
|
489
|
-
if args.dry_run:
|
|
490
|
-
return _release_dry_run(args, current_v, next_v)
|
|
491
|
-
|
|
492
|
-
# Resume-already-complete short-circuit (spec §5.5). Only when
|
|
493
|
-
# `--resume`: if all four phase signals report done, exit 0
|
|
494
|
-
# immediately with `already published` — don't call any phase
|
|
495
|
-
# helper. The mirror signal needs the public clone path resolved
|
|
496
|
-
# first; if the operator's local clone is gone (laptop restored
|
|
497
|
-
# from backup, marker deleted, etc.) but the gh release exists,
|
|
498
|
-
# we trust the gh release as proof that phase 3 must have
|
|
499
|
-
# succeeded earlier — phase 4 is the LAST phase, so its presence
|
|
500
|
-
# implies phase 3 landed. That's the only path where we accept a
|
|
501
|
-
# `mirror_done = True` without a successful probe. Partial-publish
|
|
502
|
-
# cases (`gh_done=False`) skip the discovery probe entirely and
|
|
503
|
-
# fall through to the phase loop, where discovery will surface
|
|
504
|
-
# exit 2 with the right error UX. `--no-publish` resume skips the
|
|
505
|
-
# phase-3 / phase-4 portion of the gate.
|
|
506
|
-
if args.resume:
|
|
507
|
-
stamp_done, _ = _cctally()._release_phase_stamp_done(next_v)
|
|
508
|
-
tag_done = _cctally()._release_phase_tag_done(next_v, args.remote)
|
|
509
|
-
if args.no_publish:
|
|
510
|
-
all_done = stamp_done and tag_done
|
|
511
|
-
else:
|
|
512
|
-
gh_done = _cctally()._release_phase_gh_done(next_v)
|
|
513
|
-
if gh_done:
|
|
514
|
-
# gh release exists — phase 4 is the last phase, so
|
|
515
|
-
# phase 3 MUST have landed. Probe mirror as best-effort:
|
|
516
|
-
# success confirms; discovery failure trusts gh.
|
|
517
|
-
try:
|
|
518
|
-
public_clone = _cctally()._release_discover_public_clone(args)
|
|
519
|
-
mirror_done = _cctally()._release_phase_mirror_done(
|
|
520
|
-
next_v, public_clone
|
|
521
|
-
)
|
|
522
|
-
except SystemExit:
|
|
523
|
-
print(
|
|
524
|
-
f"release v{next_v}: public clone not discoverable "
|
|
525
|
-
"for mirror probe; trusting gh release existence "
|
|
526
|
-
"as proof of phase 3 completion",
|
|
527
|
-
file=sys.stderr,
|
|
528
|
-
)
|
|
529
|
-
mirror_done = True
|
|
530
|
-
else:
|
|
531
|
-
mirror_done = False
|
|
532
|
-
# Phases 5 + 6: include npm and brew in the all-done
|
|
533
|
-
# computation so a fully-shipped multi-channel release
|
|
534
|
-
# short-circuits with `already published`. `--skip-npm` /
|
|
535
|
-
# `--skip-brew` operator-escape-hatches count as "done"
|
|
536
|
-
# for gate purposes — if the operator opted out, the
|
|
537
|
-
# channel is not pending. Pre-releases skip Phase 6
|
|
538
|
-
# categorically (Homebrew tracks stable only), so
|
|
539
|
-
# `is_prerelease` collapses brew_done to True.
|
|
540
|
-
npm_done = args.skip_npm or _cctally()._release_phase_npm_done(next_v)
|
|
541
|
-
is_prerelease = "-" in next_v
|
|
542
|
-
if args.skip_brew or is_prerelease:
|
|
543
|
-
brew_done = True
|
|
544
|
-
else:
|
|
545
|
-
brew_clone = _cctally()._release_discover_brew_clone(args)
|
|
546
|
-
brew_done = (
|
|
547
|
-
brew_clone is not None
|
|
548
|
-
and _cctally()._release_phase_brew_done(next_v, brew_clone)
|
|
549
|
-
)
|
|
550
|
-
all_done = (
|
|
551
|
-
stamp_done and tag_done and mirror_done and gh_done
|
|
552
|
-
and npm_done and brew_done
|
|
553
|
-
)
|
|
554
|
-
if all_done:
|
|
555
|
-
print(f"release v{next_v} already published")
|
|
556
|
-
return 0
|
|
557
|
-
|
|
558
|
-
# Real flow — phase-table style. Each helper carries its own
|
|
559
|
-
# signal-done check + short-circuit (spec §5.1), so cmd_release
|
|
560
|
-
# is just an ordered call sequence with no per-phase branching.
|
|
561
|
-
invocation = f"cctally release {args.kind or '--resume'}"
|
|
562
|
-
bump_kind = args.kind or "resume"
|
|
563
|
-
|
|
564
|
-
# Phase 1 — stamp.
|
|
565
|
-
stamp_sha = _cctally()._release_run_phase_stamp(
|
|
566
|
-
next_v, args.remote, invocation, bump_kind
|
|
567
|
-
)
|
|
568
|
-
|
|
569
|
-
# Phase 2 — tag.
|
|
570
|
-
_cctally()._release_run_phase_tag(next_v, args.remote, stamp_sha)
|
|
571
|
-
|
|
572
|
-
if args.no_publish:
|
|
573
|
-
print(
|
|
574
|
-
f"release v{next_v} stamped + tagged; "
|
|
575
|
-
"--no-publish set; phases 3-6 skipped"
|
|
576
|
-
)
|
|
577
|
-
return 0
|
|
578
|
-
|
|
579
|
-
# Phase 3 — mirror push (replay → push branch → push tag).
|
|
580
|
-
public_clone = _cctally()._release_discover_public_clone(args)
|
|
581
|
-
_cctally()._release_run_phase_mirror(next_v, public_clone, args.remote)
|
|
582
|
-
|
|
583
|
-
# Phase 4 — gh release create (auth-fallback returns 0 to keep the
|
|
584
|
-
# release "published" from phases 1-3's perspective; spec §9.2).
|
|
585
|
-
body = _cctally()._release_extract_body_from_changelog(next_v)
|
|
586
|
-
rc = _cctally()._release_run_phase_gh(next_v, body)
|
|
587
|
-
if rc != 0:
|
|
588
|
-
return rc
|
|
589
|
-
|
|
590
|
-
# Compute channel-publishing variables once. `is_prerelease` gates
|
|
591
|
-
# both Phase 5's npm dist-tag and Phase 6's run/skip decision; the
|
|
592
|
-
# `-` test holds because SemVer prereleases always carry a hyphen
|
|
593
|
-
# (e.g. `1.2.0-rc.1`). Done up-front so each phase reads cleanly.
|
|
594
|
-
is_prerelease = "-" in next_v
|
|
595
|
-
dist_tag = "next" if is_prerelease else "latest"
|
|
596
|
-
|
|
597
|
-
# Phase 5 — await npm publish via the public-repo GHA workflow
|
|
598
|
-
# (release-npm.yml), which fires on the tag pushed in Phase 3. Phase 5
|
|
599
|
-
# here is observation-only (poll `npm view` with timeout); poll-timeout
|
|
600
|
-
# returns 0 — the workflow runs independently on github.com, and
|
|
601
|
-
# `--resume` re-checks the registry. `--skip-npm` is the operator
|
|
602
|
-
# escape hatch for ad-hoc cuts.
|
|
603
|
-
if args.skip_npm:
|
|
604
|
-
print("phase 5: npm skipped (--skip-npm)")
|
|
605
|
-
else:
|
|
606
|
-
rc = _cctally()._release_run_phase_npm(next_v, public_clone, dist_tag=dist_tag)
|
|
607
|
-
if rc != 0:
|
|
608
|
-
return rc
|
|
609
|
-
|
|
610
|
-
# Phase 6 — brew formula bump (graceful skip when `release.brewClone`
|
|
611
|
-
# is unconfigured; pre-releases skipped categorically — Homebrew
|
|
612
|
-
# users track stable versions only). `--skip-brew` is the operator
|
|
613
|
-
# escape hatch; idempotent under `--resume`.
|
|
614
|
-
if args.skip_brew or is_prerelease:
|
|
615
|
-
suffix = " (pre-release)" if is_prerelease else ""
|
|
616
|
-
print(f"phase 6: brew skipped{suffix}")
|
|
617
|
-
else:
|
|
618
|
-
brew_clone = _cctally()._release_discover_brew_clone(args)
|
|
619
|
-
rc = _cctally()._release_run_phase_brew(
|
|
620
|
-
next_v,
|
|
621
|
-
brew_clone,
|
|
622
|
-
allow_downgrade=args.allow_formula_downgrade,
|
|
623
|
-
)
|
|
624
|
-
if rc != 0:
|
|
625
|
-
return rc
|
|
626
|
-
|
|
627
|
-
print(f"\nrelease v{next_v} published")
|
|
628
|
-
return 0
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
def _release_dry_run(
|
|
632
|
-
args: argparse.Namespace, current_v: str | None, next_v: str
|
|
633
|
-
) -> int:
|
|
634
|
-
"""Print what the release would do; mutate nothing (spec §10.2).
|
|
635
|
-
|
|
636
|
-
Returns 0 on a clean dry-run; 2 if the stamp itself would refuse
|
|
637
|
-
(missing CHANGELOG or empty [Unreleased]). Refusal paths print a
|
|
638
|
-
`(would refuse: ...)` line before returning so the operator sees why.
|
|
639
|
-
"""
|
|
640
|
-
today = (
|
|
641
|
-
os.environ.get("CCTALLY_RELEASE_DATE_UTC")
|
|
642
|
-
or dt.datetime.now(dt.timezone.utc).date().isoformat()
|
|
643
|
-
)
|
|
644
|
-
print(
|
|
645
|
-
f"release dry-run: v{current_v or '(none)'} → v{next_v} "
|
|
646
|
-
f"({args.kind or 'resume'} bump)"
|
|
647
|
-
)
|
|
648
|
-
print()
|
|
649
|
-
branch = subprocess.check_output(
|
|
650
|
-
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
651
|
-
text=True,
|
|
652
|
-
cwd=str(_cctally().CHANGELOG_PATH.parent),
|
|
653
|
-
).strip()
|
|
654
|
-
print(f"Branch: {branch}")
|
|
655
|
-
print(f"Remote: {args.remote}")
|
|
656
|
-
print("Working tree: clean")
|
|
657
|
-
print(f"Resume: {'yes' if args.resume else 'no'}")
|
|
658
|
-
print()
|
|
659
|
-
# Phase 1 — stamp diff preview.
|
|
660
|
-
print("Phase 1 — Stamp CHANGELOG.md")
|
|
661
|
-
print("─" * 41)
|
|
662
|
-
if not args.resume:
|
|
663
|
-
try:
|
|
664
|
-
old_text = _cctally().CHANGELOG_PATH.read_text(encoding="utf-8")
|
|
665
|
-
except FileNotFoundError:
|
|
666
|
-
print(f"(would refuse: CHANGELOG.md not found at {_cctally().CHANGELOG_PATH})")
|
|
667
|
-
return 2
|
|
668
|
-
try:
|
|
669
|
-
new_text, body = _release_stamp_changelog(old_text, next_v, today)
|
|
670
|
-
except ValueError as e:
|
|
671
|
-
print(f"(would refuse: {e})")
|
|
672
|
-
return 2
|
|
673
|
-
import difflib
|
|
674
|
-
|
|
675
|
-
diff = difflib.unified_diff(
|
|
676
|
-
old_text.splitlines(keepends=True),
|
|
677
|
-
new_text.splitlines(keepends=True),
|
|
678
|
-
fromfile="a/CHANGELOG.md",
|
|
679
|
-
tofile="b/CHANGELOG.md",
|
|
680
|
-
n=3,
|
|
681
|
-
)
|
|
682
|
-
sys.stdout.write("".join(diff))
|
|
683
|
-
else:
|
|
684
|
-
body = "(resume — stamp already done)"
|
|
685
|
-
print()
|
|
686
|
-
# Phase 2 — tag annotation preview.
|
|
687
|
-
print(f"Phase 2 — Tag v{next_v}")
|
|
688
|
-
print("─" * 41)
|
|
689
|
-
print("Annotation:")
|
|
690
|
-
print(f"v{next_v}")
|
|
691
|
-
print()
|
|
692
|
-
print(body)
|
|
693
|
-
print()
|
|
694
|
-
# Phase 3 — mirror push plan.
|
|
695
|
-
print("Phase 3 — Mirror push")
|
|
696
|
-
print("─" * 41)
|
|
697
|
-
if args.no_publish:
|
|
698
|
-
print("(skipped under --no-publish)")
|
|
699
|
-
else:
|
|
700
|
-
print("Would invoke (3 sub-steps):")
|
|
701
|
-
print(" bin/cctally-mirror-public --yes <public-clone>")
|
|
702
|
-
print(" git -C <public-clone> push origin <current-branch>")
|
|
703
|
-
print(f" git -C <public-clone> push origin refs/tags/v{next_v}")
|
|
704
|
-
print()
|
|
705
|
-
# Phase 4 — GitHub Release plan.
|
|
706
|
-
print("Phase 4 — GitHub Release")
|
|
707
|
-
print("─" * 41)
|
|
708
|
-
if args.no_publish:
|
|
709
|
-
print("(skipped under --no-publish)")
|
|
710
|
-
else:
|
|
711
|
-
prerelease_flag = " --prerelease" if "-" in next_v else ""
|
|
712
|
-
print("Would invoke:")
|
|
713
|
-
print(f" gh release create v{next_v} --repo {_cctally().PUBLIC_REPO} \\")
|
|
714
|
-
print(
|
|
715
|
-
f" --title v{next_v} --notes-file <body>{prerelease_flag}"
|
|
716
|
-
)
|
|
717
|
-
print()
|
|
718
|
-
print("Body (also used for Phase 4 GH Release notes):")
|
|
719
|
-
print(body)
|
|
720
|
-
print()
|
|
721
|
-
# Phase 5 — npm publish plan.
|
|
722
|
-
print("Phase 5 — npm publish")
|
|
723
|
-
print("─" * 41)
|
|
724
|
-
if args.no_publish or getattr(args, "skip_npm", False):
|
|
725
|
-
reason = "--no-publish" if args.no_publish else "--skip-npm"
|
|
726
|
-
print(f"(skipped under {reason})")
|
|
727
|
-
else:
|
|
728
|
-
dist_tag = "next" if "-" in next_v else "latest"
|
|
729
|
-
print(
|
|
730
|
-
f"Would await: cctally@{next_v} on npmjs.org via GHA workflow "
|
|
731
|
-
f"(release-npm.yml in public repo; tag={dist_tag})"
|
|
732
|
-
)
|
|
733
|
-
print()
|
|
734
|
-
# Phase 6 — brew formula bump plan.
|
|
735
|
-
print("Phase 6 — brew formula bump")
|
|
736
|
-
print("─" * 41)
|
|
737
|
-
if args.no_publish or getattr(args, "skip_brew", False):
|
|
738
|
-
reason = "--no-publish" if args.no_publish else "--skip-brew"
|
|
739
|
-
print(f"(skipped under {reason})")
|
|
740
|
-
elif "-" in next_v:
|
|
741
|
-
print(f"(skipped: pre-release v{next_v})")
|
|
742
|
-
else:
|
|
743
|
-
print(f"Would bump: Formula/cctally.rb in <brew-clone> to v{next_v}")
|
|
744
|
-
print(
|
|
745
|
-
f" url: https://github.com/{_cctally().PUBLIC_REPO}/archive/"
|
|
746
|
-
f"refs/tags/v{next_v}.tar.gz"
|
|
747
|
-
)
|
|
748
|
-
print(" sha256: <would compute by downloading at run time>")
|
|
749
|
-
print()
|
|
750
|
-
print("dry-run complete; no state mutated; exit 0")
|
|
751
|
-
return 0
|