cctally 1.8.2 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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