Skip to content
Merged
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
109 changes: 108 additions & 1 deletion .github/workflows/0-ci-build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
120 changes: 120 additions & 0 deletions tools/check_markdown.py
Original file line number Diff line number Diff line change
@@ -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<text>[^\]]*)\]\((?P<dest>[^()\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())
Loading
Loading