cctally 1.2.0 → 1.3.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 CHANGED
@@ -5,6 +5,11 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.3.0] - 2026-05-08
9
+
10
+ ### Changed
11
+ - `release` Phase 5 now publishes to npm via a GitHub Actions OIDC trusted-publisher workflow in the public repo, with `npm publish --provenance` for supply-chain attestation. The release script no longer invokes `npm publish` locally — it polls `npm view` until the workflow lands the version. Eliminates the prior failure mode where passkey-based npm 2FA would block `npm publish` from a non-interactive subprocess.
12
+
8
13
  ## [1.2.0] - 2026-05-08
9
14
 
10
15
  ### Added
package/README.md CHANGED
@@ -33,7 +33,7 @@ npm install -g cctally
33
33
  cctally setup
34
34
  ```
35
35
 
36
- The npm package is a thin Node shim around the bundled Python script — no postinstall, no native build. Set `CCTALLY_PYTHON=/path/to/python3` if `python3` isn't on your PATH.
36
+ Needs Python 3. If `cctally setup` fails with "python3 not found", install it with `brew install python` (macOS) and try again.
37
37
 
38
38
  ### From source
39
39
 
package/bin/cctally CHANGED
@@ -1446,79 +1446,82 @@ def _release_run_phase_gh(version: str, body: str) -> int:
1446
1446
  return 0
1447
1447
 
1448
1448
 
1449
+ _RELEASE_NPM_POLL_TIMEOUT_S_DEFAULT = 300.0
1450
+ _RELEASE_NPM_POLL_INTERVAL_S_DEFAULT = 10.0
1451
+
1452
+
1453
+ def _release_npm_poll_timing() -> tuple[float, float]:
1454
+ """Return (timeout_s, interval_s) honoring env-hook overrides.
1455
+
1456
+ Hidden env hooks (mirrors ``CCTALLY_RELEASE_DATE_UTC``):
1457
+ - CCTALLY_RELEASE_NPM_POLL_TIMEOUT_S
1458
+ - CCTALLY_RELEASE_NPM_POLL_INTERVAL_S
1459
+ Used by the harness (and pytest) to make Phase 5 fixtures deterministic.
1460
+ Not in --help.
1461
+ """
1462
+ def _f(name: str, default: float) -> float:
1463
+ try:
1464
+ return float(os.environ[name])
1465
+ except (KeyError, ValueError):
1466
+ return default
1467
+ return (
1468
+ _f("CCTALLY_RELEASE_NPM_POLL_TIMEOUT_S", _RELEASE_NPM_POLL_TIMEOUT_S_DEFAULT),
1469
+ _f("CCTALLY_RELEASE_NPM_POLL_INTERVAL_S", _RELEASE_NPM_POLL_INTERVAL_S_DEFAULT),
1470
+ )
1471
+
1472
+
1449
1473
  def _release_run_phase_npm(
1450
1474
  version: str,
1451
1475
  public_clone: pathlib.Path,
1452
1476
  *,
1453
1477
  dist_tag: str,
1454
1478
  ) -> int:
1455
- """Phase 5 — publish to npm from the public clone.
1479
+ """Phase 5 — wait for the public-repo GHA workflow to publish ``cctally@<v>``.
1456
1480
 
1457
- ``dist_tag`` is ``"latest"`` for stable releases, ``"next"`` for
1458
- prereleases. Idempotent: short-circuits when
1459
- ``_release_phase_npm_done`` reports the version is already on npm.
1481
+ Phase 3 pushes ``v<version>`` to ``omrikais/cctally``; the workflow at
1482
+ ``.github/workflows/release-npm.yml`` fires on tag-push and runs
1483
+ ``npm publish --provenance`` via OIDC trusted publisher (no NPM_TOKEN,
1484
+ no operator 2FA round-trip — fixes the passkey-in-subprocess failure
1485
+ mode where npm 2FA blocks ``npm publish`` from a non-interactive
1486
+ subprocess).
1460
1487
 
1461
- Auth-fallback parity with Phase 4: when ``npm whoami`` exits
1462
- nonzero (or npm isn't on PATH at all), prints a copy-pasteable
1463
- command to publish manually and returns 0 phases 1-4 already
1464
- succeeded, the release IS published to GitHub from the user's
1465
- perspective; npm is the third channel and treated as polish.
1488
+ Phase 5 here is observation-only: poll ``npm view cctally@<v>`` until
1489
+ it appears, with timeout. ``cctally`` never invokes ``npm publish``
1490
+ locally anymore Trusted Publisher binds the right to publish to the
1491
+ public-repo workflow, not to the operator's `npm login` token.
1466
1492
 
1467
- Returns:
1468
- - ``0`` on successful publish, idempotent short-circuit, OR
1469
- auth-fallback.
1470
- - ``3`` on hard failure of ``npm publish`` after auth was
1471
- confirmed OK; ``--resume`` retries.
1493
+ Returns ``0`` on observed success OR poll-timeout (soft-success: phases
1494
+ 1-4 landed; the workflow is either succeeding or visibly failing on
1495
+ github.com; ``--resume`` re-checks the registry).
1496
+
1497
+ Timing overridable via ``CCTALLY_RELEASE_NPM_POLL_TIMEOUT_S`` and
1498
+ ``CCTALLY_RELEASE_NPM_POLL_INTERVAL_S`` env vars.
1472
1499
  """
1473
- print(f"phase 5: npm publish (tag={dist_tag})")
1500
+ print(f"phase 5: await npm publish via GHA (tag={dist_tag})")
1474
1501
  if _release_phase_npm_done(version):
1475
1502
  print(f" cctally@{version} already on npm — skipping.")
1476
1503
  return 0
1477
1504
 
1478
- # Auth probe. Wrap in try/except so missing npm-on-PATH falls
1479
- # cleanly into the auth-fallback branch (existing release-test
1480
- # scenarios run without npm on PATH and depend on this not crashing).
1481
- try:
1482
- auth = subprocess.run(
1483
- ["npm", "whoami"],
1484
- capture_output=True,
1485
- text=True,
1486
- check=False,
1487
- timeout=15,
1488
- )
1489
- except (subprocess.TimeoutExpired, FileNotFoundError):
1490
- print(
1491
- f"\n npm not on PATH or unresponsive. After installing npm and "
1492
- f"`npm login`, run:\n"
1493
- f" cd {public_clone} && npm publish --tag {dist_tag}\n",
1494
- file=sys.stderr,
1495
- )
1496
- return 0
1497
- if auth.returncode != 0:
1498
- print(
1499
- f"\n npm not authenticated. After `npm login`, run:\n"
1500
- f" cd {public_clone} && npm publish --tag {dist_tag}\n",
1501
- file=sys.stderr,
1502
- )
1503
- return 0
1504
-
1505
- pub = subprocess.run(
1506
- ["npm", "publish", "--tag", dist_tag],
1507
- cwd=str(public_clone),
1508
- check=False,
1509
- )
1510
- if pub.returncode != 0:
1511
- return 3
1512
-
1513
- if not _release_phase_npm_done(version):
1514
- print(
1515
- f" warning: `npm publish` returned 0 but `npm view "
1516
- f"cctally@{version}` doesn't see the version yet. Registry "
1517
- f"propagation lag is normal; check `npm view cctally version` "
1518
- f"in 30s.",
1519
- file=sys.stderr,
1520
- )
1521
- return 0
1505
+ timeout_s, interval_s = _release_npm_poll_timing()
1506
+ deadline = time.monotonic() + timeout_s
1507
+ while True:
1508
+ if _release_phase_npm_done(version):
1509
+ print(f" cctally@{version} on npm registry ✓")
1510
+ return 0
1511
+ if time.monotonic() >= deadline:
1512
+ print(
1513
+ f"\n timed out after {timeout_s:.0f}s waiting for "
1514
+ f"cctally@{version} on npm. The GHA workflow may still be "
1515
+ f"running or have failed — check:\n"
1516
+ f" https://github.com/{PUBLIC_REPO}/actions\n"
1517
+ f" Re-run `cctally release --resume` once the workflow "
1518
+ f"completes, or for emergency manual publish:\n"
1519
+ f" cd {public_clone} && npm publish --access public "
1520
+ f"--tag {dist_tag}\n",
1521
+ file=sys.stderr,
1522
+ )
1523
+ return 0
1524
+ time.sleep(interval_s)
1522
1525
 
1523
1526
 
1524
1527
  def _release_run_phase_brew(
@@ -1538,8 +1541,8 @@ def _release_run_phase_brew(
1538
1541
  version (idempotency under ``--resume``).
1539
1542
  - Dirty working tree — refuses with exit 2 and points the operator
1540
1543
  at ``--resume``.
1541
- - Push failure — auth-fallback parity with Phases 4 and 5: prints
1542
- the exact recovery command and returns 0. Phases 1-5 already
1544
+ - Push failure — auth-fallback parity with Phase 4: prints the
1545
+ exact recovery command and returns 0. Phases 1-5 already
1543
1546
  succeeded, the release IS published from the user's
1544
1547
  perspective; the brew tap is the third channel and treated as
1545
1548
  polish.
@@ -1645,7 +1648,7 @@ def _release_run_phase_brew(
1645
1648
  f"refs/tags/v{version}:refs/tags/v{version}\n",
1646
1649
  file=sys.stderr,
1647
1650
  )
1648
- return 0 # auth-fallback semantics; mirrors Phase 5.
1651
+ return 0 # auth-fallback semantics; parity with Phase 4.
1649
1652
 
1650
1653
  return 0
1651
1654
 
@@ -1846,10 +1849,12 @@ def cmd_release(args: argparse.Namespace) -> int:
1846
1849
  is_prerelease = "-" in next_v
1847
1850
  dist_tag = "next" if is_prerelease else "latest"
1848
1851
 
1849
- # Phase 5 — npm publish (auth-fallback returns 0 to keep the release
1850
- # "published" from phases 1-4's perspective). `--skip-npm` is the
1851
- # operator escape hatch for ad-hoc cuts; idempotent re-running
1852
- # `--resume` without it picks Phase 5 back up.
1852
+ # Phase 5 — await npm publish via the public-repo GHA workflow
1853
+ # (release-npm.yml), which fires on the tag pushed in Phase 3. Phase 5
1854
+ # here is observation-only (poll `npm view` with timeout); poll-timeout
1855
+ # returns 0 the workflow runs independently on github.com, and
1856
+ # `--resume` re-checks the registry. `--skip-npm` is the operator
1857
+ # escape hatch for ad-hoc cuts.
1853
1858
  if args.skip_npm:
1854
1859
  print("phase 5: npm skipped (--skip-npm)")
1855
1860
  else:
@@ -1973,8 +1978,8 @@ def _release_dry_run(
1973
1978
  else:
1974
1979
  dist_tag = "next" if "-" in next_v else "latest"
1975
1980
  print(
1976
- f"Would publish: cctally@{next_v} to npmjs.org "
1977
- f"with --tag {dist_tag}"
1981
+ f"Would await: cctally@{next_v} on npmjs.org via GHA workflow "
1982
+ f"(release-npm.yml in public repo; tag={dist_tag})"
1978
1983
  )
1979
1984
  print()
1980
1985
  # Phase 6 — brew formula bump plan.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Track Claude Code subscription usage as a weekly $-per-1% trend. Local web dashboard, terminal UI, forecasts, and threshold alerts.",
5
5
  "homepage": "https://github.com/omrikais/cctally",
6
6
  "repository": {