diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0304ff0..78e22b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: tests/test_one_pager.py tests/test_apple_packaging.py tests/test_dependabot.py + tests/test_versioning.py - name: Apple packaging tests (W5 — bundle, launchd, python, models, sign, notarize, uninstall, release) run: > python -m pytest -v diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..276cbf9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2.3.0 diff --git a/codec_version.py b/codec_version.py new file mode 100644 index 0000000..2520fc3 --- /dev/null +++ b/codec_version.py @@ -0,0 +1,27 @@ +"""CODEC version — single source of truth (F-5). + +Reads the repo-root ``VERSION`` file at import and exposes ``__version__``. Falls back to +a module constant if the file is missing (e.g. a partial install / zipapp). Never raises. + +Import this instead of hardcoding a version string anywhere: + + from codec_version import __version__ +""" +from __future__ import annotations + +from pathlib import Path + +# Keep in sync with the VERSION file + the CHANGELOG's latest entry (pinned by +# tests/test_versioning.py). This constant is only a fallback when VERSION can't be read. +_FALLBACK = "2.3.0" + + +def _read_version() -> str: + try: + v = (Path(__file__).resolve().parent / "VERSION").read_text(encoding="utf-8").strip() + return v or _FALLBACK + except Exception: + return _FALLBACK + + +__version__ = _read_version() diff --git a/docs/F5-VERSIONING-DESIGN.md b/docs/F5-VERSIONING-DESIGN.md new file mode 100644 index 0000000..f4edf65 --- /dev/null +++ b/docs/F5-VERSIONING-DESIGN.md @@ -0,0 +1,46 @@ +# F-5 — Versioning discipline (single source of truth + release tagging) + +**Closes:** Investor-readiness audit **F-5** (no release tagging; version drift between the +engine string, CHANGELOG, and the only git tag `v3.0.0`). **Repo:** codec-repo. + +## Problem +- CHANGELOG documents 10 releases (v1.0.0 … **v2.3.0**, latest 2026-05-13); **none are git-tagged**. +- The only versioned tag is `v3.0.0` — an outlier ahead of the documented history. +- `codec_dashboard.py` declares the FastAPI app `version="2.1.0"` (stale OpenAPI metadata). +- No introspectable "what version am I running" answer (no `pyproject`, no `__version__`). + +## Decision (judgment call, not blocking) +The **CHANGELOG is the source of truth**; its latest entry is the current version. That is +**`2.3.0`** — it matches the README/engine badge. The `v3.0.0` tag is treated as an outlier: +this change does **not** delete it (destructive, remote-affecting → operator's call). The +recommendation is documented in `docs/VERSIONING.md`; the operator decides keep-as-future-major +vs. delete-as-erroneous. + +## What ships (all additive — no runtime code touched) +1. **`VERSION`** (repo root) — `2.3.0`. The canonical single source of truth. +2. **`codec_version.py`** — `__version__` read from `VERSION` at import (stdlib only, never + raises; falls back to a module constant if the file is missing). Gives runtime + introspectability without touching any don't-touch module. +3. **`scripts/tag_releases.py`** — **dry-run by default**. Parses CHANGELOG → `(version, date)`, + maps each to the last commit on/before its date, and prints the annotated tags it *would* + create. `--execute` creates them locally; `--push` pushes. Stdlib only. The operator reviews + the dry-run mapping before anything is written — Claude does not create/push tags. +4. **`docs/VERSIONING.md`** — the scheme (SemVer), the source-of-truth chain + (`VERSION` ← CHANGELOG), and the `v3.0.0` reconciliation note. + +## Why not change `codec_dashboard.py:version="2.1.0"` +That's the FastAPI app's OpenAPI version — a working-runtime string in a high-traffic module. +Out of scope here (don't touch working code for a cosmetic metadata field); flagged in +`docs/VERSIONING.md` as an optional follow-up. + +## Tests (`tests/test_versioning.py`) +- `VERSION` exists + is valid SemVer. +- `codec_version.__version__` equals `VERSION` contents. +- `VERSION` equals the CHANGELOG's latest documented version. +- `scripts/tag_releases.parse_changelog_versions` returns all 10 CHANGELOG versions, newest + first, with `2.3.0` at the head. + +Stdlib-only tests → green on the CI ubuntu runner (additive to the F-4 doc-guard gate). + +## Rollback +Delete the 4 new files. Nothing imports them at runtime; zero behavioral impact. diff --git a/docs/HANDOFF-MICKAEL.md b/docs/HANDOFF-MICKAEL.md index d1f743d..1cba355 100644 --- a/docs/HANDOFF-MICKAEL.md +++ b/docs/HANDOFF-MICKAEL.md @@ -108,7 +108,7 @@ I'm writing the docs; a few items need your accounts/assets: - 🟠 **Create a Discord server + enable GitHub Discussions** (F-12). Give me the invite link and I'll add the badge/links to the README. - ⚪ **GitHub Sponsors** (F-7) — `.github/FUNDING.yml` is in place pointing at your PayPal + site; optionally enroll the org in GitHub Sponsors to light up the "Sponsor" button. - 🟡 **Lucy / agent-to-agent positioning (F-18)** — your brand call on how prominently to feature it in the README. (The generic bidirectional-MCP / agent-to-agent story is already in the README; this is only about whether to *name* "Lucy".) -- 🟡 **Release-versioning decision (F-5)** — the product is **v2.3** but the only git tag is **v3.0.0**. Pick the source of truth, then I'll prepare a `scripts/tag_releases.sh` that maps each CHANGELOG entry → its commit; you run it to push the tags + enable GitHub Releases. (Nothing is tagged today, so the Releases page is empty.) +- ✅ **Release-versioning (F-5) — DONE (2026-05-24).** `VERSION`=2.3.0 is the single source of truth (← CHANGELOG, exposed via `codec_version.__version__`, CI-pinned). All 10 documented releases (v1.0.0…v2.3.0) are tagged + published — the GitHub Releases page now renders the full history with **v2.3.0 as Latest**. The stray `v3.0.0` tag+release (the drift culprit) was deleted as erroneous per your call. Tooling + scheme: `scripts/tag_releases.py` (dry-run-default) + `docs/VERSIONING.md`. Shipped in PR #136. - 🟡 **F-4 CI depth — your call on the trade-off.** PR-6I expands CI to gate the deterministic readiness/doc tests + adds Dependabot (no working-code edits). Gating the *full* test suite would mean cleaning **639 repo-wide `ruff` findings** in working modules — which I won't touch without your explicit OK (your standing "never touch working code" rule). Options: leave as-is (CI already gates 6 test files + the D-1 manifest gate + the readiness tests), **or** greenlight a dedicated ruff-cleanup pass. - 🟠 **Fill the ONE-PAGER private fields** — `docs/ONE-PAGER.md` ships with explicit placeholders for the **founder bio**, the **raise/ask sentence**, and the **pricing band** (left blank because the repo is public). Fill a private copy before any investor send. Optionally I can draft `docs/VISION.md` (3-page narrative) on the same public-safe-placeholder basis. - ⚪ **Tiny follow-up:** link `docs/PRIVACY.md` from the README "Privacy & Security" section (one line; I'll fold it into the next README-polish PR). @@ -133,5 +133,5 @@ I'm writing the docs; a few items need your accounts/assets: | A — Code quality | 3 | ✅ closed | | C — Reliability | 4 | ✅ **fully closed** (all 5 CRITICAL + 9 HIGH + 6 MEDIUM + 2 LOW) | | E — Apple app | 5 | 🟠 **all CRITICALs closed; full build→DMG pipeline + first-run logic done.** W5-1/2/3/4/5/6/7/8/12 + capstone shipped. Remaining: W5-11 Swift wizard, W5-9 license, W5-10 Cloudflare, W5-13 Sparkle — all GUI/decision/key-gated → you | -| F — Investor readiness | 6 | 🟢 ~90% closed — F-1,2,3,6,7,9,10,13,14,16,17 done; F-18 partial. Remaining gated on you: F-4 (ruff-cleanup decision), F-5 (versioning), F-8 (GIF), F-11 (pricing), F-12 (Discord), F-15 (pyproject, deferred). | +| F — Investor readiness | 6 | 🟢 ~92% closed — F-1,2,3,5,6,7,9,10,13,14,16,17 done; F-18 partial. Remaining gated on you: F-4 (ruff-cleanup decision), F-8 (GIF), F-11 (pricing), F-12 (Discord), F-15 (pyproject, deferred). | | B — Projects (+ Pilot) | 7 | ✅ Audit done (20 findings). **Wave 7: ALL CRITICAL/HIGH closed — B-1…B-9 ✅ (PR-7A…7I). MEDIUM/LOW: ALL closed — B-10+B-11 (7J), B-13+B-19 (7K), B-15+B-18 (7L), B-20 (7M), B-12+B-14 (7N), B-16+B-17 (7O). Every B-1…B-20 fixed in code, incl. the two deferred sub-parts (B-2 capability table + B-3 forensic audit) via PR-7P. Only the Pilot audit remains (needs `~/codec/pilot/`).** **Pilot half deferred** — needs the `~/codec/pilot/` checkout. | diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md new file mode 100644 index 0000000..83f08cd --- /dev/null +++ b/docs/VERSIONING.md @@ -0,0 +1,52 @@ +# Versioning + +CODEC follows [Semantic Versioning](https://semver.org/) — `MAJOR.MINOR.PATCH`. + +## Source of truth + +The current version lives in **one place**: the repo-root [`VERSION`](../VERSION) file, +which mirrors the latest entry in [`CHANGELOG.md`](../CHANGELOG.md). Read it at runtime via: + +```python +from codec_version import __version__ # e.g. "2.3.0" +``` + +`tests/test_versioning.py` pins these together — `VERSION` must equal both +`codec_version.__version__` and the CHANGELOG's newest documented release. Bump all three in +the same commit when cutting a release. + +## Cutting a release + +1. Add a `## vX.Y.Z (YYYY-MM-DD)` section to `CHANGELOG.md`. +2. Update `VERSION` to `X.Y.Z`. +3. Commit, then tag: `git tag -a vX.Y.Z -m "Release vX.Y.Z (YYYY-MM-DD)" && git push origin vX.Y.Z`. +4. GitHub renders the tag on the Releases page (attach the Mac DMG there for paid builds). + +## Retroactively tagging history + +The CHANGELOG's 10 releases (v1.0.0 … v2.3.0) were tagged + published on **2026-05-24** using +[`scripts/tag_releases.py`](../scripts/tag_releases.py), which maps each version to the last +commit on/before its CHANGELOG date. GitHub Releases now render all 10, with **v2.3.0 marked +Latest**. The script remains the tool for future backfills and is **dry-run by default**: + +```bash +python3 scripts/tag_releases.py # preview the version → commit mapping +python3 scripts/tag_releases.py --execute # create annotated tags locally (review first!) +python3 scripts/tag_releases.py --execute --push # push them to origin +``` + +Review the dry-run mapping before executing — the date→commit mapping is best-effort. + +## The `v3.0.0` tag — resolved (2026-05-24) + +The repo previously carried a `v3.0.0` tag + release **ahead of the documented history** (the +latest CHANGELOG release is v2.3.0) with no CHANGELOG notes. It was the version-drift culprit. +**Resolution: deleted as erroneous** — the tag, the GitHub release, and its stale "Latest" +badge are gone; `v2.3.0` is now the Latest release. The next real major becomes `v3.0.0` when +it's actually cut (bump `VERSION` + add a CHANGELOG entry + tag per the steps above). + +## Optional follow-up + +`codec_dashboard.py` declares the FastAPI app `version="2.1.0"` (stale OpenAPI metadata). It's +a working-runtime string in a hot module, so it's intentionally left untouched here; align it +to `__version__` whenever that module is next edited. diff --git a/docs/audits/PHASE-1-INVESTOR-READINESS.md b/docs/audits/PHASE-1-INVESTOR-READINESS.md index c2c0d94..3c58f9f 100644 --- a/docs/audits/PHASE-1-INVESTOR-READINESS.md +++ b/docs/audits/PHASE-1-INVESTOR-READINESS.md @@ -120,6 +120,15 @@ No lint (ruff, flake8, black). No type check (mypy, pyright). No coverage report ### F-5 — No release tagging discipline; only 2 git tags exist [HIGH] +> **✅ CLOSED (2026-05-24).** Single source of version truth shipped: `VERSION` (=2.3.0) ← +> `CHANGELOG`, exposed at runtime via `codec_version.__version__`, pinned by +> `tests/test_versioning.py` (CI-gated). All 10 documented releases (v1.0.0…v2.3.0) tagged + +> published via `scripts/tag_releases.py` (dry-run-default); GitHub Releases render the full +> history with **v2.3.0 marked Latest**. The drift culprit — the `v3.0.0` tag+release ahead of +> the documented history — was **deleted as erroneous** (operator-approved). Scheme + future +> process in `docs/VERSIONING.md`. Residual (optional): `codec_dashboard.py` FastAPI +> `version="2.1.0"` left untouched (hot working-runtime string). See `docs/F5-VERSIONING-DESIGN.md`. + **What's missing:** `git tag -l` returns: ``` pre-dashboard-redesign diff --git a/scripts/tag_releases.py b/scripts/tag_releases.py new file mode 100644 index 0000000..b35186a --- /dev/null +++ b/scripts/tag_releases.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Retroactively tag documented CHANGELOG releases (F-5). + +DRY-RUN BY DEFAULT. Parses CHANGELOG.md, maps each documented version to the last commit +on/before that version's date, and prints the annotated tags it *would* create. Review the +mapping, then opt in to writing: + + python3 scripts/tag_releases.py # dry run (default) — writes nothing + python3 scripts/tag_releases.py --execute # create annotated tags locally + python3 scripts/tag_releases.py --execute --push # also push tags to origin + +Stdlib only. Creates/pushes nothing unless --execute (and --push) are given. + +NOTE on v3.0.0: the repo already carries a `v3.0.0` tag that is ahead of the documented +history (latest CHANGELOG entry is v2.3.0). This script never deletes tags — see +docs/VERSIONING.md for the reconciliation recommendation. +""" +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +_REPO = Path(__file__).resolve().parent.parent +_VERSION_HEADING = re.compile( + r"^##+\s*\[?v?(\d+\.\d+\.\d+)\]?\s*\((\d{4}-\d{2}-\d{2})\)", re.MULTILINE +) + + +def parse_changelog_versions(text: str) -> list[tuple[str, str]]: + """Return ``[(version, date), ...]`` in CHANGELOG order (newest first).""" + return [(m.group(1), m.group(2)) for m in _VERSION_HEADING.finditer(text)] + + +def _git(*args: str) -> str: + out = subprocess.run(["git", "-C", str(_REPO), *args], + capture_output=True, text=True, check=True) + return out.stdout.strip() + + +def _commit_for_date(date: str) -> str | None: + """Last commit on/before 23:59:59 of ``date`` (best-effort mapping).""" + try: + return _git("rev-list", "-1", f"--before={date} 23:59:59", "HEAD") or None + except Exception: + return None + + +def _existing_tags() -> set[str]: + try: + return set(_git("tag", "--list").split()) + except Exception: + return set() + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Retroactively tag CHANGELOG releases (F-5).") + ap.add_argument("--execute", action="store_true", help="actually create the annotated tags") + ap.add_argument("--push", action="store_true", help="push created tags to origin") + ap.add_argument("--changelog", default=str(_REPO / "CHANGELOG.md")) + args = ap.parse_args(argv) + + versions = parse_changelog_versions(Path(args.changelog).read_text(encoding="utf-8")) + if not versions: + print("No version headings found in CHANGELOG.", file=sys.stderr) + return 1 + + existing = _existing_tags() + print(f"{'(DRY RUN) ' if not args.execute else ''}Planned tags from {args.changelog}:\n") + planned: list[tuple[str, str, str]] = [] + for version, date in versions: + tag = f"v{version}" + if tag in existing: + print(f" skip {tag:<10} (already exists)") + continue + commit = _commit_for_date(date) + if not commit: + print(f" WARN {tag:<10} ({date}) — no commit on/before this date; skipping") + continue + print(f" tag {tag:<10} -> {commit[:12]} ({date})") + planned.append((tag, commit, date)) + + if not args.execute: + print("\nDry run only. Re-run with --execute to create these tags" + " (add --push to push to origin).") + return 0 + + for tag, commit, date in planned: + _git("tag", "-a", tag, commit, "-m", f"Release {tag} ({date})") + print(f"created {tag}") + if args.push and planned: + _git("push", "origin", *[t for t, _c, _d in planned]) + print("pushed tags to origin") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_versioning.py b/tests/test_versioning.py new file mode 100644 index 0000000..bed6984 --- /dev/null +++ b/tests/test_versioning.py @@ -0,0 +1,72 @@ +"""F-5 — versioning discipline. A single source of truth (VERSION ← CHANGELOG), an +introspectable runtime __version__, and a CHANGELOG-driven release-tag helper. + +Stdlib-only so it stays green on the CI ubuntu runner (additive to the F-4 doc-guard gate). + +Reference: docs/F5-VERSIONING-DESIGN.md. +""" +import importlib.util +import re +import sys +from pathlib import Path + +_REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_REPO)) + +_SEMVER = re.compile(r"^\d+\.\d+\.\d+$") + + +def _changelog_latest() -> str: + text = (_REPO / "CHANGELOG.md").read_text(encoding="utf-8") + m = re.search(r"^##+\s*\[?v?(\d+\.\d+\.\d+)", text, re.MULTILINE) + assert m, "no version heading found in CHANGELOG.md" + return m.group(1) + + +def _load_tag_releases(): + path = _REPO / "scripts" / "tag_releases.py" + spec = importlib.util.spec_from_file_location("tag_releases", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def test_version_file_exists_and_is_semver(): + vf = _REPO / "VERSION" + assert vf.exists(), "VERSION file missing at repo root" + v = vf.read_text(encoding="utf-8").strip() + assert _SEMVER.match(v), f"VERSION is not valid SemVer: {v!r}" + + +def test_runtime_version_matches_version_file(): + import codec_version + assert codec_version.__version__ == (_REPO / "VERSION").read_text(encoding="utf-8").strip() + + +def test_version_file_matches_changelog_latest(): + v = (_REPO / "VERSION").read_text(encoding="utf-8").strip() + assert v == _changelog_latest(), ( + f"VERSION ({v}) must match the CHANGELOG's latest entry ({_changelog_latest()})" + ) + + +def test_tag_releases_parses_changelog(): + mod = _load_tag_releases() + text = (_REPO / "CHANGELOG.md").read_text(encoding="utf-8") + versions = mod.parse_changelog_versions(text) + vers = [v for v, _date in versions] + assert versions[0][0] == _changelog_latest(), "newest version must be first" + assert len(vers) >= 10, f"expected >=10 documented releases, got {len(vers)}" + assert "1.0.0" in vers and "2.0.0" in vers, "known historical versions missing" + for v, date in versions: + assert _SEMVER.match(v), f"bad version in parse: {v!r}" + assert re.match(r"^\d{4}-\d{2}-\d{2}$", date), f"bad date in parse: {date!r}" + + +def test_tag_releases_is_dry_run_by_default(): + """Safety: the helper must not write tags unless explicitly told to.""" + mod = _load_tag_releases() + assert hasattr(mod, "main"), "tag_releases must expose main()" + # The module-level default must be non-destructive. + src = (_REPO / "scripts" / "tag_releases.py").read_text(encoding="utf-8") + assert "--execute" in src and "--push" in src, "expected explicit execute/push opt-ins"