|
| 1 | +name: Lint |
| 2 | + |
| 3 | +on: |
| 4 | + pull_request: |
| 5 | + push: |
| 6 | + branches: |
| 7 | + - main |
| 8 | + |
| 9 | +jobs: |
| 10 | + lint: |
| 11 | + runs-on: ubuntu-latest |
| 12 | + permissions: |
| 13 | + contents: read |
| 14 | + pull-requests: read |
| 15 | + checks: write |
| 16 | + |
| 17 | + steps: |
| 18 | + - name: Checkout code |
| 19 | + uses: actions/checkout@v4 |
| 20 | + with: |
| 21 | + fetch-depth: 0 |
| 22 | + |
| 23 | + - name: Set up JDK 17 |
| 24 | + uses: actions/setup-java@v4 |
| 25 | + with: |
| 26 | + java-version: '17' |
| 27 | + distribution: 'temurin' |
| 28 | + |
| 29 | + - name: Setup Gradle |
| 30 | + uses: gradle/actions/setup-gradle@v4 |
| 31 | + |
| 32 | + - name: Run lintDebug |
| 33 | + run: ./gradlew lintDebug --console=plain 2>&1 | tee lint.log |
| 34 | + continue-on-error: true |
| 35 | + |
| 36 | + - name: Collect changed files |
| 37 | + id: changed |
| 38 | + run: | |
| 39 | + set -euo pipefail |
| 40 | + HEAD_SHA="${{ github.sha }}" |
| 41 | + if [ "${{ github.event_name }}" = "pull_request" ]; then |
| 42 | + BASE="${{ github.event.pull_request.base.sha }}" |
| 43 | + HEAD_SHA="${{ github.event.pull_request.head.sha }}" |
| 44 | + else |
| 45 | + BEFORE="${{ github.event.before }}" |
| 46 | + ZERO="0000000000000000000000000000000000000000" |
| 47 | + if [ -z "$BEFORE" ] || [ "$BEFORE" = "$ZERO" ] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then |
| 48 | + BASE="${HEAD_SHA}^" |
| 49 | + else |
| 50 | + BASE="$BEFORE" |
| 51 | + fi |
| 52 | + fi |
| 53 | + git diff --name-only "$BASE" "$HEAD_SHA" > changed.txt |
| 54 | + echo "changed files:" |
| 55 | + cat changed.txt |
| 56 | +
|
| 57 | + - name: Emit annotations |
| 58 | + shell: python |
| 59 | + run: | |
| 60 | + import os, re, sys |
| 61 | + import xml.etree.ElementTree as ET |
| 62 | +
|
| 63 | + workspace = os.environ.get("GITHUB_WORKSPACE", "") |
| 64 | +
|
| 65 | + # None = no changed-files list available, annotate everything. |
| 66 | + # set() = list was produced but empty, annotate nothing. |
| 67 | + changed = None |
| 68 | + try: |
| 69 | + with open("changed.txt") as f: |
| 70 | + changed = {line.strip() for line in f if line.strip()} |
| 71 | + except FileNotFoundError: |
| 72 | + pass |
| 73 | +
|
| 74 | + errors = 0 |
| 75 | + warnings = 0 |
| 76 | +
|
| 77 | + def emit(sev, path, line, col, msg): |
| 78 | + global errors, warnings |
| 79 | + if workspace and path.startswith(workspace + "/"): |
| 80 | + path = path[len(workspace) + 1:] |
| 81 | + if changed is not None and path not in changed: |
| 82 | + return |
| 83 | + if sev == "error": |
| 84 | + errors += 1 |
| 85 | + elif sev == "warning": |
| 86 | + warnings += 1 |
| 87 | + msg = msg.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") |
| 88 | + loc = f"file={path},line={line}" |
| 89 | + if col: |
| 90 | + loc += f",col={col}" |
| 91 | + print(f"::{sev} {loc}::{msg}") |
| 92 | +
|
| 93 | + # Kotlin compiler diagnostics from the Gradle console log. |
| 94 | + compiler_pattern = re.compile( |
| 95 | + r"^(?P<sev>[we]):\s+file://(?P<path>[^:]+):(?P<line>\d+):(?P<col>\d+)\s+(?P<msg>.*)$" |
| 96 | + ) |
| 97 | + try: |
| 98 | + with open("lint.log", "r", errors="replace") as f: |
| 99 | + for raw in f: |
| 100 | + m = compiler_pattern.match(raw.rstrip("\n")) |
| 101 | + if not m: |
| 102 | + continue |
| 103 | + sev = "error" if m.group("sev") == "e" else "warning" |
| 104 | + emit(sev, m.group("path"), m.group("line"), m.group("col"), m.group("msg")) |
| 105 | + except FileNotFoundError: |
| 106 | + pass |
| 107 | +
|
| 108 | + # Android Lint issues from the XML report. The text report drops |
| 109 | + # column info and the console only prints a summary, so the XML is |
| 110 | + # the authoritative source for inline annotations. |
| 111 | + severity_map = { |
| 112 | + "Fatal": "error", |
| 113 | + "Error": "error", |
| 114 | + "Warning": "warning", |
| 115 | + "Informational": "notice", |
| 116 | + } |
| 117 | + lint_xml = "app/build/reports/lint-results-debug.xml" |
| 118 | + if os.path.exists(lint_xml): |
| 119 | + tree = ET.parse(lint_xml) |
| 120 | + for issue in tree.getroot().findall("issue"): |
| 121 | + sev = severity_map.get(issue.get("severity", ""), "warning") |
| 122 | + if sev == "notice": |
| 123 | + continue |
| 124 | + base_msg = issue.get("message", "").strip() |
| 125 | + issue_id = issue.get("id", "") |
| 126 | + msg = f"{base_msg} [{issue_id}]" if issue_id else base_msg |
| 127 | + for loc in issue.findall("location"): |
| 128 | + path = loc.get("file", "") |
| 129 | + if not path: |
| 130 | + continue |
| 131 | + emit(sev, path, loc.get("line") or "1", loc.get("column") or "", msg) |
| 132 | +
|
| 133 | + print(f"::notice::Lint (changed files) — errors: {errors}, warnings: {warnings}") |
| 134 | + sys.exit(1 if errors > 0 else 0) |
| 135 | +
|
| 136 | + - name: Upload lint log |
| 137 | + if: always() |
| 138 | + uses: actions/upload-artifact@v4 |
| 139 | + with: |
| 140 | + name: lint-log |
| 141 | + path: lint.log |
0 commit comments