MotorSignal-App/.github/scripts/detect-android-changes.cjs

292 lines
8.0 KiB
JavaScript

#!/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 changes, or build config
return (
p.startsWith('android/') ||
p.startsWith('.github/') ||
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
};