name: Code Quality Checks on: workflow_dispatch: pull_request: merge_group: push: branches: [ main, develop ] permissions: {} # Enrich gradle.properties for CI/CD env: GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache jobs: checkScript: name: Search for forbidden patterns runs-on: ubuntu-latest steps: - name: Free Disk Space (Ubuntu) uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be with: # This might remove tools that are actually needed, if set to "true" but frees about 6 GB tool-cache: true # All of these default to true, but we should only need the 'android' one (and maybe swap-storage?) android: false dotnet: true haskell: true # This takes way too long to run (~2 minutes) and it saves only ~5.5GB large-packages: false docker-images: true swap-storage: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Add SSH private keys for submodule repositories uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} with: ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }} - name: Clone submodules if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: Run code quality check suite run: ./tools/check/check_code_quality.sh checkScreenshot: name: Search for invalid screenshot files runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python 3.12 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: 3.14 - name: Search for invalid screenshot files run: ./tools/test/checkInvalidScreenshots.py checkDependencies: name: Search for invalid dependencies runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Use JDK 21 uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Set up Python 3.12 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: 3.14 - name: Search for invalid dependencies run: ./tools/dependencies/checkDependencies.py # Code checks konsist: name: Konsist tests runs-on: ubuntu-latest # Allow all jobs on main and develop. Just one per PR. concurrency: group: ${{ github.ref == 'refs/heads/main' && format('check-konsist-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-konsist-develop-{0}', github.sha) || format('check-konsist-{0}', github.ref) }} cancel-in-progress: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Ensure we are building the branch and not the branch after being merged on develop # https://github.com/actions/checkout/issues/881 ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} persist-credentials: false - name: Add SSH private keys for submodule repositories uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} with: ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }} - name: Clone submodules if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: Use JDK 21 uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Konsist tests run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon - name: Upload reports if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: konsist-report path: | **/build/reports/**/*.* compose: name: Compose tests runs-on: ubuntu-latest # Allow all jobs on main and develop. Just one per PR. concurrency: group: ${{ github.ref == 'refs/heads/main' && format('check-compose-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-compose-develop-{0}', github.sha) || format('check-compose-{0}', github.ref) }} cancel-in-progress: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Ensure we are building the branch and not the branch after being merged on develop # https://github.com/actions/checkout/issues/881 ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} persist-credentials: false - name: Add SSH private keys for submodule repositories uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} with: ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }} - name: Clone submodules if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: Use JDK 21 uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run compose tests run: ./tools/compose/check_stability.sh lint: name: Android lint check runs-on: ubuntu-latest # Allow all jobs on main and develop. Just one per PR. concurrency: group: ${{ github.ref == 'refs/heads/main' && format('check-lint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-lint-develop-{0}', github.sha) || format('check-lint-{0}', github.ref) }} cancel-in-progress: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Ensure we are building the branch and not the branch after being merged on develop # https://github.com/actions/checkout/issues/881 ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} persist-credentials: false - name: Add SSH private keys for submodule repositories uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} with: ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }} - name: Clone submodules if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: Use JDK 21 uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Build Gplay Debug run: ./gradlew :app:compileGplayDebugKotlin $CI_GRADLE_ARG_PROPERTIES - name: Build Fdroid Debug run: ./gradlew :app:compileFdroidDebugKotlin $CI_GRADLE_ARG_PROPERTIES - name: Run lint run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue - name: Upload reports if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: linting-report path: | **/build/reports/**/*.* detekt: name: Detekt checks runs-on: ubuntu-latest # Allow all jobs on main and develop. Just one per PR. concurrency: group: ${{ github.ref == 'refs/heads/main' && format('check-detekt-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-detekt-develop-{0}', github.sha) || format('check-detekt-{0}', github.ref) }} cancel-in-progress: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Ensure we are building the branch and not the branch after being merged on develop # https://github.com/actions/checkout/issues/881 ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} persist-credentials: false - name: Add SSH private keys for submodule repositories uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} with: ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }} - name: Clone submodules if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: Use JDK 21 uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Detekt run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon - name: Upload reports if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: detekt-report path: | **/build/reports/**/*.* ktlint: name: Ktlint checks runs-on: ubuntu-latest # Allow all jobs on main and develop. Just one per PR. concurrency: group: ${{ github.ref == 'refs/heads/main' && format('check-ktlint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-ktlint-develop-{0}', github.sha) || format('check-ktlint-{0}', github.ref) }} cancel-in-progress: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Ensure we are building the branch and not the branch after being merged on develop # https://github.com/actions/checkout/issues/881 ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} persist-credentials: false - name: Add SSH private keys for submodule repositories uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} with: ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }} - name: Clone submodules if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: Use JDK 21 uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Ktlint check run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES - name: Upload reports if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ktlint-report path: | **/build/reports/**/*.* docs: name: Doc checks runs-on: ubuntu-latest # Allow all jobs on main and develop. Just one per PR. concurrency: group: ${{ github.ref == 'refs/heads/main' && format('check-docs-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-docs-develop-{0}', github.sha) || format('check-docs-{0}', github.ref) }} cancel-in-progress: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Ensure we are building the branch and not the branch after being merged on develop # https://github.com/actions/checkout/issues/881 ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} persist-credentials: false - name: Add SSH private keys for submodule repositories uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} with: ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }} - name: Clone submodules if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: Run docs check # This is equivalent to `./gradlew checkDocs`, but we avoid having to install java and gradle run: python3 ./tools/docs/generate_toc.py --verify ./*.md docs/**/*.md # Note: to auto fix issues you can use the following command: # shellcheck -f diff | git apply shellcheck: name: Check shell scripts runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Run shellcheck uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # v2.0.0 with: severity: warning zizmor: name: Run zizmor runs-on: ubuntu-latest permissions: security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 upload_reports: name: Project Check Suite runs-on: ubuntu-latest needs: [konsist, lint, ktlint, detekt] # Run even if a previous job failed: that is precisely when there are issues to report. if: ${{ !cancelled() }} permissions: # Required to list the files modified by the pull request. pull-requests: read steps: - name: Download reports from previous jobs uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - name: Annotate the lint, ktlint and detekt issues uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | // GitHub only surfaces a handful of annotations per level and per step, so cap what we emit // and let the job summary carry the full list. const MAX_ANNOTATIONS = 20; const MAX_SUMMARY_ROWS = 200; function unescapeXml(value) { return value .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, "\"") .replace(/'/g, "'") .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code))) .replace(/&/g, "&"); } function attribute(tag, name) { const match = new RegExp(`\\b${name}="([^"]*)"`).exec(tag); return match ? unescapeXml(match[1]) : undefined; } // Paths in the reports are absolute paths on the runner which generated them, i.e. // /home/runner/work///. const workspace = `/work/${context.repo.repo}/${context.repo.repo}/`; function repoRelative(path) { const index = path.indexOf(workspace); return index === -1 ? path : path.slice(index + workspace.length); } // Checkstyle XML, as produced by ktlint and detekt: // function parseCheckstyle(content) { const issues = []; const fileRegex = /]*?)(\/>|>([\s\S]*?)<\/file>)/g; let file; while ((file = fileRegex.exec(content)) !== null) { const path = attribute(file[1], "name"); if (!path) continue; const errorRegex = /]*?)\/?>/g; let error; while ((error = errorRegex.exec(file[3] ?? "")) !== null) { issues.push({ file: repoRelative(path), line: Number(attribute(error[1], "line")) || 1, column: Number(attribute(error[1], "column")) || undefined, severity: (attribute(error[1], "severity") ?? "warning").toLowerCase(), rule: attribute(error[1], "source"), message: attribute(error[1], "message") ?? "", }); } } return issues; } // Android lint XML, which is not Checkstyle: // function parseAndroidLint(content) { const issues = []; const issueRegex = /]*?)(\/>|>([\s\S]*?)<\/issue>)/g; let issue; while ((issue = issueRegex.exec(content)) !== null) { const location = /]*?)\/?>/.exec(issue[3] ?? ""); if (!location) continue; const path = attribute(location[1], "file"); if (!path) continue; issues.push({ file: repoRelative(path), line: Number(attribute(location[1], "line")) || 1, column: Number(attribute(location[1], "column")) || undefined, severity: (attribute(issue[1], "severity") ?? "warning").toLowerCase(), rule: attribute(issue[1], "id"), message: attribute(issue[1], "message") ?? "", }); } return issues; } const reports = await (await glob.create("**/reports/**/*.xml")).glob(); core.info(`Found ${reports.length} XML report(s).`); const fs = require("fs"); let issues = []; for (const report of reports) { const content = fs.readFileSync(report, "utf8"); if (content.includes(" file.filename)); issues = issues.filter(issue => changed.has(issue.file)); } const seen = new Set(); issues = issues.filter(issue => { const key = `${issue.file}:${issue.line}:${issue.rule}:${issue.message}`; if (seen.has(key)) return false; seen.add(key); return true; }); if (issues.length === 0) { core.info("No issue found in the files modified by this pull request."); return; } const level = severity => { if (severity === "error" || severity === "fatal") return "error"; if (severity === "warning") return "warning"; return "notice"; }; const rank = { error: 0, warning: 1, notice: 2 }; issues.sort((a, b) => rank[level(a.severity)] - rank[level(b.severity)] || a.file.localeCompare(b.file) || a.line - b.line ); issues.slice(0, MAX_ANNOTATIONS).forEach(issue => { const annotation = { title: issue.rule, file: issue.file, startLine: issue.line, startColumn: issue.column, }; core[level(issue.severity)](issue.message, annotation); }); core.summary.addHeading(`${issues.length} code quality issue(s)`, 3); core.summary.addTable([ [{ data: "Severity", header: true }, { data: "File", header: true }, { data: "Rule", header: true }, { data: "Message", header: true }], ...issues.slice(0, MAX_SUMMARY_ROWS).map(issue => [ level(issue.severity), `${issue.file}:${issue.line}`, issue.rule ?? "", issue.message, ]), ]); if (issues.length > MAX_SUMMARY_ROWS) { core.summary.addRaw(`…and ${issues.length - MAX_SUMMARY_ROWS} more, see the uploaded reports.`, true); } await core.summary.write();