Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/docs-ci.yml
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions .github/workflows/sync-readme-toc.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ This is an attempt to document some of the discussion and information about the

## Table of Contents

<!-- toc:start -->
* [openpilot/etc. on Toyota/Lexus/Subaru with TSK/ECU SECURITY KEY/SecOC](#openpilotetc-on-toyotalexussubaru-with-tskecu-security-keysecoc)
* [Background](#background)
* [Cars](#cars)
Expand All @@ -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)
<!-- toc:end -->

---

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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`
Expand All @@ -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`
Expand All @@ -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`
Expand All @@ -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`
Expand Down
52 changes: 52 additions & 0 deletions scripts/check_readme_regressions.py
Original file line number Diff line number Diff line change
@@ -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())
187 changes: 187 additions & 0 deletions scripts/sync_readme_toc.py
Original file line number Diff line number Diff line change
@@ -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:start -->"
TOC_END = "<!-- 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())