diff --git a/.github/workflows/docs-ci.yml b/.github/workflows/docs-ci.yml new file mode 100644 index 0000000..75c6573 --- /dev/null +++ b/.github/workflows/docs-ci.yml @@ -0,0 +1,28 @@ +name: docs-ci + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + readme: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Verify README TOC is synchronized + run: python scripts/sync_readme_toc.py --check + + - name: Verify README regression checks + run: python scripts/check_readme_regressions.py diff --git a/.github/workflows/sync-readme-toc.yml b/.github/workflows/sync-readme-toc.yml new file mode 100644 index 0000000..8dd165c --- /dev/null +++ b/.github/workflows/sync-readme-toc.yml @@ -0,0 +1,40 @@ +name: sync-readme-toc + +on: + push: + branches: + - main + paths: + - README.md + - scripts/sync_readme_toc.py + workflow_dispatch: + +permissions: + contents: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Synchronize README TOC + run: python scripts/sync_readme_toc.py + + - name: Commit and push updated TOC + run: | + if git diff --quiet -- README.md; then + echo "README.md table of contents already up to date." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add README.md + git commit -m "docs: sync README table of contents" + git push diff --git a/README.md b/README.md index 67fa2c2..c3032c1 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ This is an attempt to document some of the discussion and information about the ## Table of Contents + * [openpilot/etc. on Toyota/Lexus/Subaru with TSK/ECU SECURITY KEY/SecOC](#openpilotetc-on-toyotalexussubaru-with-tskecu-security-keysecoc) * [Background](#background) * [Cars](#cars) @@ -39,6 +40,7 @@ This is an attempt to document some of the discussion and information about the * [Forks](#forks) * [Discords of Note](#discords-of-note) * [Current History](#current-history) + --- @@ -71,6 +73,11 @@ The following is not comprehensive. ## Cars +> [!IMPORTANT] +> TSS version alone does **not** tell you whether a vehicle has TSK/SecOC or whether the current exploit path will work. Region, model year, plant/origin, and the exact ECU hardware matter more. +> +> Example: the EU 2024 RAV4 Hybrid appears to have TSK, while the US 2025 RAV4 appears not to have TSK according to TechInfo checks. Do not assume "TSS 2.0" means "works" or "TSS 3.0" means "doesn't work". + ### 🟢 Successfully running openpilot These cars can run openpilot but are not listed on https://comma.ai/vehicles#toyota or [CARS.md](https://github.com/commaai/openpilot/blob/master/docs/CARS.md) because comma.ai (the company) understandably doesn't want to own the security key hacking process. @@ -509,6 +516,8 @@ Is a GUI button too easy for your engineering spirit? Here is how to [extract th ## openpilot +[![nightly-dev last commit](https://img.shields.io/github/last-commit/commaai/openpilot/nightly-dev?label=nightly-dev%20last%20commit)](https://github.com/commaai/openpilot/commits/nightly-dev/) + URL * C4: `commaai/nightly-dev` * C3X: `commaai/nightly-dev` @@ -523,6 +532,10 @@ Notes ## sunnypilot +[![staging last commit](https://img.shields.io/github/last-commit/sunnypilot/sunnypilot/staging?label=staging%20last%20commit)](https://github.com/sunnypilot/sunnypilot/commits/staging/) +[![release-tizi last commit](https://img.shields.io/github/last-commit/sunnypilot/sunnypilot/release-tizi?label=release-tizi%20last%20commit)](https://github.com/sunnypilot/sunnypilot/commits/release-tizi/) +[![staging-tici last commit](https://img.shields.io/github/last-commit/sunnypilot/sunnypilot/staging-tici?label=staging-tici%20last%20commit)](https://github.com/sunnypilot/sunnypilot/commits/staging-tici/) + URL [(Source)](https://community.sunnypilot.ai/t/recommended-branch-installations/235) * C4: `staging.sunnypilot.ai` * C3X: `release.sunnypilot.ai` @@ -538,6 +551,8 @@ Notes ## FrogPilot +[![FrogPilot last commit](https://img.shields.io/github/last-commit/FrogAi/FrogPilot/FrogPilot?label=FrogPilot%20last%20commit)](https://github.com/FrogAi/FrogPilot/commits/FrogPilot/) + URL * C4: Not yet supported * C3X: `frogpilot.download` @@ -550,6 +565,9 @@ Notes ## SatoPilot +[![personal3 last commit](https://img.shields.io/github/last-commit/AlexandreSato/openpilot/personal3?label=personal3%20last%20commit)](https://github.com/AlexandreSato/openpilot/commits/personal3/) +[![extract_secoc_key_btn last commit](https://img.shields.io/github/last-commit/AlexandreSato/openpilot/extract_secoc_key_btn?label=extract_secoc_key_btn%20last%20commit)](https://github.com/AlexandreSato/openpilot/commits/extract_secoc_key_btn/) + URL * C4: Not yet supported * C3X: `alexandresato/personal3` diff --git a/scripts/check_readme_regressions.py b/scripts/check_readme_regressions.py new file mode 100755 index 0000000..d50cc06 --- /dev/null +++ b/scripts/check_readme_regressions.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import pathlib +import re +import sys + +README_PATH = pathlib.Path("README.md") +README_TEXT = README_PATH.read_text(encoding="utf-8") + +CHECKS: list[tuple[str, str]] = [ + ( + "TSS version FAQ note", + r"TSS version alone does \*\*not\*\* tell you whether a vehicle has TSK/SecOC", + ), + ( + "EU vs US RAV4 FAQ note", + r"EU 2024 RAV4 Hybrid appears to have TSK, while the US 2025 RAV4 appears not to have TSK", + ), + ( + "openpilot freshness badge", + r"img\.shields\.io/github/last-commit/commaai/openpilot/nightly-dev", + ), + ( + "sunnypilot release-tizi freshness badge", + r"img\.shields\.io/github/last-commit/sunnypilot/sunnypilot/release-tizi", + ), + ( + "FrogPilot freshness badge", + r"img\.shields\.io/github/last-commit/FrogAi/FrogPilot/FrogPilot", + ), + ( + "SatoPilot personal3 freshness badge", + r"img\.shields\.io/github/last-commit/AlexandreSato/openpilot/personal3", + ), +] + + +def main() -> int: + failed = False + for label, pattern in CHECKS: + if re.search(pattern, README_TEXT, flags=re.MULTILINE) is None: + failed = True + print(f"Missing expected README content: {label}", file=sys.stderr) + if failed: + return 1 + print("README regression checks passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sync_readme_toc.py b/scripts/sync_readme_toc.py new file mode 100755 index 0000000..a7ff573 --- /dev/null +++ b/scripts/sync_readme_toc.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import difflib +import pathlib +import re +import sys +import unicodedata + +README_PATH = pathlib.Path("README.md") +TOC_START = "" +TOC_END = "" +DOC_TITLE = "openpilot/etc. on Toyota/Lexus/Subaru with TSK/ECU SECURITY KEY/SecOC" +SKIP_TITLES = { + "Table of Contents", + "Bounty Statuses", + "Pictures of TSK'd and non-TSK'd Camera ECUs", +} +HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$") +FENCE_RE = re.compile(r"^(```|~~~)") +IMAGE_RE = re.compile(r"!\[([^\]]*)\]\([^)]+\)") +LINK_RE = re.compile(r"\[([^\]]+)\]\([^)]+\)") +INLINE_CODE_RE = re.compile(r"`([^`]+)`") +FOOTNOTE_RE = re.compile(r"\[\^[^\]]+\]") +HTML_TAG_RE = re.compile(r"<[^>]+>") +TOC_WITH_MARKERS_RE = re.compile( + rf"{re.escape(TOC_START)}\n.*?\n{re.escape(TOC_END)}", + re.DOTALL, +) + + +def _plain_heading(text: str) -> str: + text = text.strip() + text = re.sub(r"\s+#+$", "", text).strip() + text = IMAGE_RE.sub(lambda m: m.group(1), text) + text = LINK_RE.sub(lambda m: m.group(1), text) + text = INLINE_CODE_RE.sub(lambda m: m.group(1), text) + text = FOOTNOTE_RE.sub("", text) + text = HTML_TAG_RE.sub("", text) + text = re.sub(r"[*_~]", "", text) + text = re.sub(r"\\", "", text) + text = re.sub(r"\s+", " ", text).strip() + return text + + +def _slugify(text: str, seen: dict[str, int]) -> str: + normalized = unicodedata.normalize("NFKD", text) + chars: list[str] = [] + for ch in normalized.lower(): + category = unicodedata.category(ch) + if ch in {" ", "-"}: + chars.append(ch) + elif category.startswith("L") or category.startswith("N"): + chars.append(ch) + slug = "".join(chars) + slug = re.sub(r"\s+", "-", slug) + slug = re.sub(r"-{2,}", "-", slug) + count = seen.get(slug, 0) + seen[slug] = count + 1 + return slug if count == 0 else f"{slug}-{count}" + + +def _toc_depth( + level: int, + title: str, + current_h1: str | None, + current_h2: str | None, +) -> int | None: + if title in SKIP_TITLES or title == "Table of Contents": + return None + if title == "Discords of Note": + return 0 + if current_h1 == DOC_TITLE: + if level == 1: + return 0 + if level == 2: + return 1 + if current_h2 == "Cars" and level == 3: + return 2 + if current_h2 == "Cars" and level == 4: + return 3 + return None + if current_h1 == "Setup Guide": + if level == 1: + return 0 + if level == 2: + return 1 + return None + if current_h1 == "Forks": + if level == 1: + return 0 + return None + if current_h1 == "Current History": + if level == 1: + return 0 + return None + return None + + +def build_toc(readme_text: str) -> str: + current_h1: str | None = None + current_h2: str | None = None + seen_slugs: dict[str, int] = {} + items: list[tuple[int, str, str]] = [] + in_fence = False + + for line in readme_text.splitlines(): + if FENCE_RE.match(line): + in_fence = not in_fence + continue + if in_fence: + continue + match = HEADING_RE.match(line) + if not match: + continue + level = len(match.group(1)) + title = _plain_heading(match.group(2)) + if level == 1: + current_h1 = title + current_h2 = None + elif level == 2: + current_h2 = title + slug = _slugify(title, seen_slugs) + depth = _toc_depth(level, title, current_h1, current_h2) + if depth is None: + continue + items.append((depth, title, slug)) + + lines = [f"{' ' * depth}* [{title}](#{slug})" for depth, title, slug in items] + return "\n".join(lines) + + +def replace_toc_block(readme_text: str, toc_text: str) -> str: + marked_block = f"{TOC_START}\n{toc_text}\n{TOC_END}" + if TOC_START in readme_text and TOC_END in readme_text: + return TOC_WITH_MARKERS_RE.sub(marked_block, readme_text, count=1) + + anchor = "## Table of Contents\n\n" + separator = "\n---\n" + if anchor not in readme_text: + raise RuntimeError("Could not find '## Table of Contents' heading in README.md") + start = readme_text.index(anchor) + len(anchor) + end = readme_text.find(separator, start) + if end == -1: + raise RuntimeError("Could not find end of Table of Contents block in README.md") + return readme_text[:start] + marked_block + readme_text[end:] + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Synchronize the README table of contents." + ) + parser.add_argument( + "--check", + action="store_true", + help="Fail instead of rewriting when the TOC is out of date.", + ) + args = parser.parse_args() + + readme_text = README_PATH.read_text(encoding="utf-8") + toc_text = build_toc(readme_text) + updated_text = replace_toc_block(readme_text, toc_text) + + if args.check: + if updated_text != readme_text: + diff = difflib.unified_diff( + readme_text.splitlines(), + updated_text.splitlines(), + fromfile="README.md", + tofile="README.md (expected)", + lineterm="", + ) + for line in diff: + print(line) + print("README.md table of contents is out of date.", file=sys.stderr) + return 1 + print("README.md table of contents is up to date.") + return 0 + + README_PATH.write_text(updated_text, encoding="utf-8") + print("Synchronized README.md table of contents.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())