#!/usr/bin/env node 'use strict'; const fs = require('node:fs'); const path = require('node:path'); const { execFileSync } = require('node:child_process'); const SHA_REGEX = /^[0-9a-fA-F]{40}$/; const ZERO_SHA_REGEX = /^0{40}$/; function normalizePath(filePath) { if (typeof filePath !== 'string') return ''; return filePath.replace(/\\/g, '/').replace(/^\.\//, '').trim(); } function isValidSha(sha) { if (typeof sha !== 'string') return false; return SHA_REGEX.test(sha.trim()); } function isZeroSha(sha) { if (typeof sha !== 'string') return false; return ZERO_SHA_REGEX.test(sha.trim()); } function isAppPath(filePath) { const p = normalizePath(filePath); if (!p) return false; return ( p.startsWith('lib/') || p.startsWith('test/') || p.startsWith('integration_test/') || p.startsWith('android/') || p.startsWith('assets/') || p === 'pubspec.yaml' || p === 'pubspec.lock' || p === 'analysis_options.yaml' || p === 'build.yaml' ); } function isNativeBuildPath(filePath) { const p = normalizePath(filePath); if (!p) return false; // Native build requires: Android code, CI-critical files, or build config. // Only CI files that affect build behavior trigger release builds; doc/comment changes do not. return ( p.startsWith('android/') || p === '.github/workflows/android-build.yml' || p.startsWith('.github/scripts/') || p === 'pubspec.yaml' || p === 'pubspec.lock' || p === 'analysis_options.yaml' || p === 'build.yaml' ); } function isCiPath(filePath) { const p = normalizePath(filePath); if (!p) return false; return ( p === '.github/workflows/android-build.yml' || p.startsWith('.github/scripts/') ); } function classifyFiles(fileList) { if (!Array.isArray(fileList)) { return { android_changed: false, app_changed: false, native_build_changed: false, appFiles: [], ciFiles: [], unrelatedFiles: [] }; } const appFiles = []; const ciFiles = []; const unrelatedFiles = []; const nativeBuildFiles = []; for (const rawFile of fileList) { const file = normalizePath(rawFile); if (!file) continue; if (isAppPath(file)) { appFiles.push(file); } else if (isCiPath(file)) { ciFiles.push(file); } else { unrelatedFiles.push(file); } // Track files that trigger native builds (Android code, CI, config) if (isNativeBuildPath(file)) { nativeBuildFiles.push(file); } } const app_changed = appFiles.length > 0; const android_changed = appFiles.length > 0 || ciFiles.length > 0; const native_build_changed = nativeBuildFiles.length > 0; return { android_changed, app_changed, native_build_changed, appFiles, ciFiles, unrelatedFiles }; } function getChangedFiles(baseSha, headSha, repoDir = process.cwd()) { if (!isValidSha(baseSha) || !isValidSha(headSha)) { throw new Error(`Invalid git SHA: base=${baseSha}, head=${headSha}`); } // Attempt 3-dot diff (merge-base between base and head) try { const stdout = execFileSync('git', ['diff', '--name-only', `${baseSha}...${headSha}`], { cwd: repoDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); return stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean); } catch (threeDotError) { // Fall back to direct 2-commit diff try { const stdout = execFileSync('git', ['diff', '--name-only', baseSha, headSha], { cwd: repoDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); return stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean); } catch (fallbackError) { throw new Error(`git diff failed: ${threeDotError.message}; fallback failed: ${fallbackError.message}`); } } } function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {}) { const getFiles = deps.getChangedFilesFn || getChangedFiles; // Manual dispatch: always run full job if (eventName === 'workflow_dispatch') { return { android_changed: true, app_changed: true, native_build_changed: true, reason: 'Manual workflow_dispatch; full pipeline authorized', appFiles: [], ciFiles: [], unrelatedFiles: [] }; } // Push with zero or missing base SHA (e.g. new branch or missing before SHA): fail safe if (!baseSha || isZeroSha(baseSha) || !isValidSha(baseSha)) { return { android_changed: true, native_build_changed: true, app_changed: true, reason: `Zero, missing, or invalid base SHA (${baseSha}); failing safe to full build`, appFiles: [], ciFiles: [], unrelatedFiles: [] }; } // Missing or invalid head SHA: fail safe if (!headSha || isZeroSha(headSha) || !isValidSha(headSha)) { return { android_changed: true, native_build_changed: true, app_changed: true, reason: `Zero, missing, or invalid head SHA (${headSha}); failing safe to full build`, appFiles: [], ciFiles: [], unrelatedFiles: [] }; } // Identical base and head: no changes if (baseSha.trim() === headSha.trim()) { return { android_changed: false, native_build_changed: false, app_changed: false, reason: 'Base SHA and Head SHA are identical; no changes detected', appFiles: [], ciFiles: [], unrelatedFiles: [] }; } // Compare diff let files; try { files = getFiles(baseSha.trim(), headSha.trim(), repoDir); } catch (err) { return { android_changed: true, native_build_changed: true, app_changed: true, reason: `Failed to retrieve git diff: ${err.message}; failing safe to full build`, appFiles: [], ciFiles: [], unrelatedFiles: [] }; } const classification = classifyFiles(files); return { ...classification, totalFiles: files.length, reason: `Analyzed ${files.length} changed file(s): ` + `${classification.appFiles.length} app, ` + `${classification.ciFiles.length} CI, ` + `${classification.unrelatedFiles.length} unrelated` }; } function runCli() { const eventName = process.argv[2] || process.env.GITHUB_EVENT_NAME || 'pull_request'; const baseSha = process.argv[3] || process.env.BASE_SHA || ''; const headSha = process.argv[4] || process.env.HEAD_SHA || ''; console.log(`[detect-android-changes] Starting detection...`); console.log(`[detect-android-changes] Event: ${eventName}`); console.log(`[detect-android-changes] Base SHA: ${baseSha}`); console.log(`[detect-android-changes] Head SHA: ${headSha}`); const result = detectAndroidChanges({ eventName, baseSha, headSha }); console.log(`[detect-android-changes] android_changed=${result.android_changed}`); console.log(`[detect-android-changes] app_changed=${result.app_changed}`); console.log(`[detect-android-changes] native_build_changed=${result.native_build_changed}`); console.log(`[detect-android-changes] Reason: ${result.reason}`); if (result.appFiles && result.appFiles.length > 0) { console.log(`[detect-android-changes] App files (${result.appFiles.length}):`); for (const f of result.appFiles) { console.log(` + ${f}`); } } if (result.ciFiles && result.ciFiles.length > 0) { console.log(`[detect-android-changes] CI files (${result.ciFiles.length}):`); for (const f of result.ciFiles) { console.log(` * ${f}`); } } if (result.unrelatedFiles && result.unrelatedFiles.length > 0) { console.log(`[detect-android-changes] Unrelated files (${result.unrelatedFiles.length}):`); for (const f of result.unrelatedFiles) { console.log(` - ${f}`); } } const githubOutput = process.env.GITHUB_OUTPUT; if (githubOutput) { fs.appendFileSync( githubOutput, `android_changed=${result.android_changed}\napp_changed=${result.app_changed}\nnative_build_changed=${result.native_build_changed}\n` ); console.log(`[detect-android-changes] Wrote outputs to GITHUB_OUTPUT.`); } process.exit(0); } if (require.main === module) { runCli(); } module.exports = { normalizePath, isValidSha, isZeroSha, isAppPath, isCiPath, classifyFiles, getChangedFiles, detectAndroidChanges };