diff --git a/.github/workflows/0-ci-build-and-test.yml b/.github/workflows/0-ci-build-and-test.yml index 784e54d..fd306a9 100644 --- a/.github/workflows/0-ci-build-and-test.yml +++ b/.github/workflows/0-ci-build-and-test.yml @@ -3,6 +3,10 @@ name: 0 - CI Build & Test # Build + quality gate for pushes and PRs. Every check runs as its own job so a # red X names the thing that broke (unit tests vs. Android Lint vs. injected-JS # syntax) instead of collapsing several gates into one opaque failure. +# +# A documentation-only change set skips the three Gradle jobs, which have nothing +# to compile. Release tooling tests still run because they assert the README +# release metadata matches Gradle. on: push: branches: ["main"] @@ -18,13 +22,18 @@ permissions: jobs: changes: - name: Detect Android app changes + name: Detect changed areas runs-on: ubuntu-latest timeout-minutes: 5 outputs: android_app: ${{ steps.filter.outputs.android_app }} + docs: ${{ steps.filter.outputs.docs }} + docs_only: ${{ steps.detect.outputs.docs_only }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Full history so the docs-only detector can diff against the base. + fetch-depth: 0 - name: Detect Android app changes id: filter @@ -40,6 +49,96 @@ jobs: - 'gradle/**' - 'gradle/libs.versions.toml' - '.github/workflows/0-ci-build-and-test.yml' + docs: + - '**/*.md' + + - name: Detect docs-only change set + id: detect + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + # Fail-safe by construction: this step always exits 0 and always emits + # docs_only, defaulting to 'false' (= run the full suite). Any + # uncertainty - no base to diff, an unreadable diff, a single non-doc + # path - runs everything. It can over-run CI; it can never skip a + # Gradle gate on a change that touches real code. + docs_only="false" + emit() { echo "docs_only=$docs_only" >> "$GITHUB_OUTPUT"; } + trap emit EXIT + set -uo pipefail + + # Strict allowlist. A path is docs only if it carries a Markdown + # extension. Deliberately NOT docs: *.txt (requirements.txt controls + # dependencies), and any code file merely named README/CHANGELOG. + if [ -n "${BASE_SHA:-}" ] && [ -n "${HEAD_SHA:-}" ]; then + RANGE="$BASE_SHA...$HEAD_SHA" + else + RANGE="${{ github.event.before }}...${{ github.sha }}" + fi + + # --no-renames so `git mv MainActivity.kt notes.md` still reveals the + # source-file deletion instead of collapsing to a docs destination. + if ! FILES="$(git diff --name-only --no-renames "$RANGE" 2>/dev/null)"; then + echo "Could not diff $RANGE - running the full suite."; exit 0 + fi + if [ -z "$FILES" ]; then + echo "Empty diff - running the full suite."; exit 0 + fi + + echo "Changed files:"; echo "$FILES" + result="true" + while IFS= read -r file; do + [ -z "$file" ] && continue + case "$(printf '%s' "$file" | tr '[:upper:]' '[:lower:]')" in + *.md) ;; + *) echo "Non-docs path -> full suite required: $file"; result="false"; break ;; + esac + done <<< "$FILES" + docs_only="$result" + echo "docs_only=$docs_only" + + # Not a Markdown style linter. `docs-render` blocks only on breaks that render + # as literal garbage or point at a file that does not exist; `docs-links` + # checks external URLs and stays informational because the network is not a + # build dependency. + docs-render: + name: Docs - Markdown rendering and in-repo links + needs: changes + if: ${{ needs.changes.outputs.docs == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check Markdown rendering and in-repo links + run: python3 tools/check_markdown.py + + docs-links: + name: Docs - external links (informational) + needs: changes + if: ${{ needs.changes.outputs.docs == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Handed a fixed glob rather than a list built from changed filenames, so + # no attacker-controlled name from a fork PR reaches the action's shell. + - name: Broken-link check + uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2 + continue-on-error: true + with: + args: >- + --no-progress + --max-concurrency 4 + --accept 200,206,301,302,303,307,308,401,403,429 + --timeout 20 + --max-retries 2 + --exclude-path build + --exclude-path node_modules + "**/*.md" + fail: true release-tooling-tests: name: Release tooling tests @@ -105,6 +204,10 @@ jobs: unit-tests: name: Unit tests + needs: changes + # always() so a detector failure degrades to running this gate rather than + # silently skipping it; an empty docs_only is treated as "not docs-only". + if: ${{ always() && needs.changes.outputs.docs_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -134,6 +237,8 @@ jobs: android-lint: name: Android Lint + needs: changes + if: ${{ always() && needs.changes.outputs.docs_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -161,6 +266,8 @@ jobs: debug-build: name: Debug APK build + needs: changes + if: ${{ always() && needs.changes.outputs.docs_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 30 steps: diff --git a/AGENTS.md b/AGENTS.md index e40a363..f8056ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -221,7 +221,11 @@ and ends with: Keep `RELEASE.md` aligned with the workflow operator path whenever release automation changes. -Separate from release publishing, CI uses `.github/workflows/0-ci-build-and-test.yml` to gate pull requests and direct `main` pushes without signing secrets. Each check runs as its own job so a failure names the gate that broke: `release-tooling-tests`, `webui-script-syntax`, `webui-script-lint`, `unit-tests`, `android-lint`, and `debug-build`. Keep every job's `timeout-minutes` set; an untimed job can burn the six-hour runner default. Pull requests and direct `main` pushes that change Android source or build inputs also run the complete unfiltered `connectedDebugAndroidTest` suite on Android API 35 and 36. Release builds run the full API 36 suite again, then verify APK and AAB signatures before upload. Keep contributor verification steps aligned with these gates when changing build/test flow. +Separate from release publishing, CI uses `.github/workflows/0-ci-build-and-test.yml` to gate pull requests and direct `main` pushes without signing secrets. Each check runs as its own job so a failure names the gate that broke: `docs-render`, `docs-links`, `release-tooling-tests`, `webui-script-syntax`, `webui-script-lint`, `unit-tests`, `android-lint`, and `debug-build`. Keep every job's `timeout-minutes` set; an untimed job can burn the six-hour runner default. Pull requests and direct `main` pushes that change Android source or build inputs also run the complete unfiltered `connectedDebugAndroidTest` suite on Android API 35 and 36. Release builds run the full API 36 suite again, then verify APK and AAB signatures before upload. Keep contributor verification steps aligned with these gates when changing build/test flow. + +A documentation-only change set (every changed path ends in `.md`) skips `unit-tests`, `android-lint`, and `debug-build`, which have nothing to compile. The detector in the `changes` job is fail-safe by construction: it always exits 0, always emits `docs_only`, and defaults to `false`, so a missing diff base, an unreadable diff, or a single non-doc path runs the full suite. Those three jobs also use `always()` so a detector failure degrades to running them rather than skipping them. Keep `release-tooling-tests` outside the fast path — it asserts that README release metadata matches Gradle, which is exactly what a docs-only change can break. None of these are required status checks today; if that changes, convert the skipped jobs to short-circuited steps first, because a skipped required check reports as pending forever. + +Docs checks are deliberately minimal and are not a Markdown style linter. `tools/check_markdown.py` blocks only on rendering breaks (an unclosed inline link, or a destination split across a newline) and on relative links or images pointing at a file that does not exist. The `docs-links` job checks external URLs with lychee and stays `continue-on-error` because the network is not a build dependency. The injected WebUI JavaScript in `webui/HermesWebUiScripts.kt` (and the raw-string block in `MainActivity.kt`) is invisible to kotlinc and Android Lint, so a syntax error or runtime-only mistake there ships green and bricks WebUI rendering on device. `tools/extract_webui_scripts.py` pulls each Kotlin raw string out into a standalone `.js` file — padded so JavaScript line numbers match the Kotlin source, with Kotlin string templates replaced by a placeholder literal — and CI runs `node --check` plus `eslint.runtime-guard.config.mjs` over the result. That ESLint config is deliberately not a style linter: it enables only rules that catch code which parses but throws at runtime. Add a Kotlin file to `SOURCE_FILES` in the extractor when it starts carrying injected script text, and add genuinely WebUI-owned page globals to `webUiPageGlobals` rather than disabling `no-undef`. @@ -232,6 +236,7 @@ python tools/extract_webui_scripts.py Get-ChildItem build/webui-scripts/*.js | ForEach-Object { node --check $_.FullName } npm install --no-save eslint@^10 npx eslint --no-config-lookup -c eslint.runtime-guard.config.mjs "build/webui-scripts/**/*.js" +python tools/check_markdown.py ``` ## Verification diff --git a/ROADMAP.md b/ROADMAP.md index e3f491f..11e8c03 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -126,6 +126,7 @@ sketches are captured inline below. | ID | Date | Area | Summary | |---|---|---|---| +| TEST-005 | 2026-08-26 | CI / Documentation | Added documentation checks for Markdown rendering breaks, dead in-repo links, and external URLs, and gave documentation-only pull requests a fail-safe fast path that skips the Gradle jobs while keeping README release-metadata assertions running. | | TEST-004 | 2026-08-26 | CI / Testing | Split PR CI into per-check jobs (release tooling, unit tests, Android Lint, debug APK) so a failure names the gate that broke, added job timeouts, and introduced syntax plus ESLint runtime-error gates for the JavaScript Android injects into the WebUI WebView. | | REL-028 | 2026-08-26 | Release / CI | Reworked release orchestration to build immutable reviewed versions, pin external actions, generate linked GitHub/Play changelogs once, validate retry metadata against the originating run, and verify APK/AAB signatures before publishing. | | TEST-003 | 2026-08-26 | CI / Testing | Expanded QA with release-tool/workflow contract tests, Android API 35/36 instrumentation gates, share/deep-link/manifest/notification contracts, duplicate-profile rules, and deterministic GitHub update parsing. | diff --git a/tools/check_markdown.py b/tools/check_markdown.py new file mode 100644 index 0000000..c0e6c11 --- /dev/null +++ b/tools/check_markdown.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Check repository Markdown for rendering breaks and dead in-repo links. + +Deliberately NOT a Markdown style linter. It does not care about line length, +heading levels, list markers, or trailing whitespace. It reports only two things: + +1. Rendering breaks - a link whose destination is split across a newline, or an + inline link that is never closed. Both render as literal `[text](` garbage. +2. Dead in-repo links - a relative link or image whose target file does not + exist. External `http(s)`/`mailto` links are left to the link checker in CI, + which is informational because the network is not a build dependency. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +FENCE_RE = re.compile(r"^\s*(```|~~~)") +INLINE_LINK_RE = re.compile(r"(!?)\[(?P[^\]]*)\]\((?P[^()\s]*)") +# `[text](` with no destination and no closing paren on the same line. +UNCLOSED_LINK_RE = re.compile(r"!?\[[^\]]*\]\([^)]*$") +EXTERNAL_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:") + + +@dataclass(frozen=True) +class Finding: + path: Path + line: int + message: str + + def render(self, root: Path) -> str: + relative = self.path.relative_to(root).as_posix() + return f"{relative}:{self.line}: {self.message}" + + +def strip_code_fences(lines: list[str]) -> list[tuple[int, str]]: + """Return `(1-based line number, text)` for lines outside fenced code.""" + result = [] + in_fence = False + for number, line in enumerate(lines, start=1): + if FENCE_RE.match(line): + in_fence = not in_fence + continue + if not in_fence: + result.append((number, line)) + return result + + +def check_rendering_breaks(path: Path, lines: list[tuple[int, str]]) -> list[Finding]: + findings = [] + for number, line in lines: + # Inline code can legitimately contain an unbalanced bracket sequence. + without_code = re.sub(r"`[^`]*`", "", line) + if UNCLOSED_LINK_RE.search(without_code): + findings.append( + Finding(path, number, "unclosed inline link - renders as literal text") + ) + return findings + + +def check_repo_links(root: Path, path: Path, lines: list[tuple[int, str]]) -> list[Finding]: + findings = [] + for number, line in lines: + without_code = re.sub(r"`[^`]*`", "", line) + for match in INLINE_LINK_RE.finditer(without_code): + dest = match.group("dest") + if not dest or dest.startswith("#"): + continue + if EXTERNAL_SCHEME_RE.match(dest): + continue + target_path = dest.split("#", 1)[0] + if not target_path: + continue + base = root if target_path.startswith("/") else path.parent + target = (base / target_path.lstrip("/")).resolve() + if not target.exists(): + findings.append(Finding(path, number, f"link target not found: {dest}")) + return findings + + +def check_file(root: Path, path: Path) -> list[Finding]: + lines = strip_code_fences(path.read_text(encoding="utf-8").splitlines()) + return check_rendering_breaks(path, lines) + check_repo_links(root, path, lines) + + +def discover(root: Path) -> list[Path]: + skip = {".git", "build", "node_modules", ".gradle", ".idea"} + return sorted( + path + for path in root.rglob("*.md") + if not skip & set(path.relative_to(root).parts) + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("paths", nargs="*", help="Markdown files (default: whole repo)") + parser.add_argument("--root", default=str(ROOT), help="repository root") + args = parser.parse_args(argv) + + root = Path(args.root).resolve() + targets = [Path(p).resolve() for p in args.paths] if args.paths else discover(root) + targets = [path for path in targets if path.is_file()] + + findings = [finding for path in targets for finding in check_file(root, path)] + for finding in findings: + print(finding.render(root), file=sys.stderr) + + print(f"Checked {len(targets)} Markdown file(s); {len(findings)} problem(s) found.") + return 1 if findings else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/test_check_markdown.py b/tools/tests/test_check_markdown.py new file mode 100644 index 0000000..fc7067b --- /dev/null +++ b/tools/tests/test_check_markdown.py @@ -0,0 +1,93 @@ +import sys +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools")) + +from check_markdown import check_file, discover, main # noqa: E402 + + +class MarkdownCheckTests(unittest.TestCase): + def _check(self, text: str, extra_files: tuple[str, ...] = ()) -> list[str]: + with TemporaryDirectory() as tmp: + root = Path(tmp) + for name in extra_files: + target = root / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("", encoding="utf-8") + doc = root / "doc.md" + doc.write_text(text, encoding="utf-8") + return [finding.message for finding in check_file(root, doc)] + + def test_clean_document_reports_nothing(self) -> None: + self.assertEqual( + self._check("See [the guide](guide.md) and [GitHub](https://github.com).", ("guide.md",)), + [], + ) + + def test_unclosed_inline_link_is_reported(self) -> None: + findings = self._check("Read [the guide](guide.md\nfor details.") + self.assertIn("unclosed inline link - renders as literal text", findings) + + def test_destination_split_across_a_newline_is_reported(self) -> None: + findings = self._check("Read [the guide](\nguide.md) now.") + self.assertIn("unclosed inline link - renders as literal text", findings) + + def test_missing_relative_target_is_reported(self) -> None: + self.assertEqual( + self._check("See [the guide](guide.md)."), + ["link target not found: guide.md"], + ) + + def test_existing_relative_target_passes(self) -> None: + self.assertEqual(self._check("See [the guide](guide.md).", ("guide.md",)), []) + + def test_anchor_on_a_real_file_passes(self) -> None: + self.assertEqual( + self._check("See [setup](guide.md#setup).", ("guide.md",)), + [], + ) + + def test_root_relative_target_resolves_from_the_repository_root(self) -> None: + self.assertEqual( + self._check("See [the guide](/docs/guide.md).", ("docs/guide.md",)), + [], + ) + + def test_bare_anchors_and_external_schemes_are_ignored(self) -> None: + self.assertEqual( + self._check("[top](#top) [mail](mailto:a@b.c) [site](https://example.com)"), + [], + ) + + def test_image_targets_are_checked(self) -> None: + self.assertEqual( + self._check("![logo](assets/logo.png)"), + ["link target not found: assets/logo.png"], + ) + + def test_fenced_code_blocks_are_skipped(self) -> None: + text = "```\n[not a link](missing.md)\n```\n" + self.assertEqual(self._check(text), []) + + def test_inline_code_is_not_parsed_as_a_link(self) -> None: + self.assertEqual(self._check("Use `[text](dest)` as the format."), []) + + +class RepositoryDocsTests(unittest.TestCase): + def test_generated_and_vendored_directories_are_excluded(self) -> None: + discovered = {path.relative_to(ROOT).as_posix() for path in discover(ROOT)} + self.assertIn("README.md", discovered) + self.assertIn("AGENTS.md", discovered) + for path in discovered: + self.assertNotIn("node_modules", path) + self.assertFalse(path.startswith("build/"), path) + + def test_repository_markdown_is_currently_clean(self) -> None: + self.assertEqual(main(["--root", str(ROOT)]), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 989dc6b..1004207 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -68,6 +68,39 @@ def test_ci_guards_the_javascript_android_injects_into_the_webview(self) -> None self.assertTrue((ROOT / "tools" / "extract_webui_scripts.py").exists()) self.assertTrue((ROOT / "eslint.runtime-guard.config.mjs").exists()) + def test_ci_checks_documentation_when_markdown_changes(self) -> None: + workflow = read_workflow("0-ci-build-and-test.yml") + self.assertIn("\n docs-render:\n", workflow) + self.assertIn("\n docs-links:\n", workflow) + self.assertIn("python3 tools/check_markdown.py", workflow) + self.assertIn("needs.changes.outputs.docs == 'true'", workflow) + self.assertTrue((ROOT / "tools" / "check_markdown.py").exists()) + # The external link check must never block a merge on network flake. + docs_links = workflow.split("\n docs-links:\n", 1)[1].split("\n release-", 1)[0] + self.assertIn("continue-on-error: true", docs_links) + + def test_docs_only_change_sets_skip_the_gradle_gates_but_fail_safe(self) -> None: + workflow = read_workflow("0-ci-build-and-test.yml") + jobs_section = workflow.split("\njobs:\n", 1)[1] + blocks = dict( + zip( + re.findall(r"^ ([a-z0-9-]+):$", jobs_section, re.MULTILINE), + re.split(r"^ [a-z0-9-]+:$", jobs_section, flags=re.MULTILINE)[1:], + ) + ) + for job in ("unit-tests", "android-lint", "debug-build"): + self.assertIn( + "if: ${{ always() && needs.changes.outputs.docs_only != 'true' }}", + blocks[job], + msg=f"{job} must skip docs-only runs while defaulting to running", + ) + # Release tooling tests assert README release metadata, so a docs-only + # change is exactly when they matter most. + self.assertNotIn("docs_only", blocks["release-tooling-tests"]) + # The detector must default to running everything. + self.assertIn('docs_only="false"', blocks["changes"]) + self.assertIn("trap emit EXIT", blocks["changes"]) + def test_every_ci_job_declares_a_timeout(self) -> None: workflow = read_workflow("0-ci-build-and-test.yml") jobs_section = workflow.split("\njobs:\n", 1)[1]