ci: skip Android builds for unrelated changes (#16)
* ci: skip Android builds for unrelated changes * test(ci): adjust live git verification test for branch head * test(ci): make detector git test deterministic --------- Co-authored-by: Developer <dev@example.com>
This commit is contained in:
parent
0344691194
commit
d69d6119d0
|
|
@ -0,0 +1,262 @@
|
|||
#!/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 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,
|
||||
appFiles: [],
|
||||
ciFiles: [],
|
||||
unrelatedFiles: []
|
||||
};
|
||||
}
|
||||
|
||||
const appFiles = [];
|
||||
const ciFiles = [];
|
||||
const unrelatedFiles = [];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const app_changed = appFiles.length > 0;
|
||||
const android_changed = appFiles.length > 0 || ciFiles.length > 0;
|
||||
|
||||
return {
|
||||
android_changed,
|
||||
app_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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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] 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}\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
|
||||
};
|
||||
|
|
@ -0,0 +1,571 @@
|
|||
'use strict';
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const {
|
||||
normalizePath,
|
||||
isValidSha,
|
||||
isZeroSha,
|
||||
isAppPath,
|
||||
isCiPath,
|
||||
classifyFiles,
|
||||
getChangedFiles,
|
||||
detectAndroidChanges
|
||||
} = require('./detect-android-changes.cjs');
|
||||
|
||||
describe('Path Normalization and Validation', () => {
|
||||
it('normalizes Windows backslashes and leading dot-slash', () => {
|
||||
assert.strictEqual(normalizePath('lib\\main.dart'), 'lib/main.dart');
|
||||
assert.strictEqual(normalizePath('.\\android\\app\\build.gradle'), 'android/app/build.gradle');
|
||||
assert.strictEqual(normalizePath(' test/widget_test.dart '), 'test/widget_test.dart');
|
||||
assert.strictEqual(normalizePath(null), '');
|
||||
assert.strictEqual(normalizePath(undefined), '');
|
||||
});
|
||||
|
||||
it('validates 40-character hex git SHAs', () => {
|
||||
assert.strictEqual(isValidSha('03446911949ff8b27543280bfe226d9babd6fd7a'), true);
|
||||
assert.strictEqual(isValidSha('0000000000000000000000000000000000000000'), true);
|
||||
assert.strictEqual(isValidSha('abc123'), false);
|
||||
assert.strictEqual(isValidSha(''), false);
|
||||
assert.strictEqual(isValidSha(null), false);
|
||||
assert.strictEqual(isValidSha('03446911949ff8b27543280bfe226d9babd6fd7z'), false);
|
||||
});
|
||||
|
||||
it('identifies 40-zero SHAs', () => {
|
||||
assert.strictEqual(isZeroSha('0000000000000000000000000000000000000000'), true);
|
||||
assert.strictEqual(isZeroSha('03446911949ff8b27543280bfe226d9babd6fd7a'), false);
|
||||
assert.strictEqual(isZeroSha(''), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Path Classification', () => {
|
||||
it('identifies Android/Flutter app paths correctly', () => {
|
||||
assert.strictEqual(isAppPath('lib/main.dart'), true);
|
||||
assert.strictEqual(isAppPath('lib/services/telemetry_service.dart'), true);
|
||||
assert.strictEqual(isAppPath('test/unit/telemetry_test.dart'), true);
|
||||
assert.strictEqual(isAppPath('integration_test/app_test.dart'), true);
|
||||
assert.strictEqual(isAppPath('android/app/build.gradle'), true);
|
||||
assert.strictEqual(isAppPath('android/gradlew'), true);
|
||||
assert.strictEqual(isAppPath('android/local.properties'), true);
|
||||
assert.strictEqual(isAppPath('assets/images/logo.png'), true);
|
||||
assert.strictEqual(isAppPath('pubspec.yaml'), true);
|
||||
assert.strictEqual(isAppPath('pubspec.lock'), true);
|
||||
assert.strictEqual(isAppPath('analysis_options.yaml'), true);
|
||||
assert.strictEqual(isAppPath('build.yaml'), true);
|
||||
|
||||
// Negative matches for app paths
|
||||
assert.strictEqual(isAppPath('website/components/ops/support-report-modal.tsx'), false);
|
||||
assert.strictEqual(isAppPath('backend/motorsignal_api/app/main.py'), false);
|
||||
assert.strictEqual(isAppPath('docs/agents/issue-tracker.md'), false);
|
||||
assert.strictEqual(isAppPath('firebase/firestore.rules'), false);
|
||||
assert.strictEqual(isAppPath('server/waze_relay/index.js'), false);
|
||||
assert.strictEqual(isAppPath('lib_unrelated.txt'), false);
|
||||
assert.strictEqual(isAppPath('test-runner.js'), false);
|
||||
});
|
||||
|
||||
it('identifies Android CI paths correctly', () => {
|
||||
assert.strictEqual(isCiPath('.github/workflows/android-build.yml'), true);
|
||||
assert.strictEqual(isCiPath('.github/scripts/detect-android-changes.cjs'), true);
|
||||
assert.strictEqual(isCiPath('.github/scripts/detect-android-changes.test.cjs'), true);
|
||||
|
||||
// Negative matches for Android CI paths
|
||||
assert.strictEqual(isCiPath('.github/workflows/website-ci.yml'), false);
|
||||
assert.strictEqual(isCiPath('.github/workflows/backend-ci.yml'), false);
|
||||
assert.strictEqual(isCiPath('.github/workflows/waze-relay-ci.yml'), false);
|
||||
assert.strictEqual(isCiPath('.github/workflows/firebase-emulator-tests.yml'), false);
|
||||
assert.strictEqual(isCiPath('.github/pull_request_template.md'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyFiles Scenarios', () => {
|
||||
it('classifies website-only changes as false', () => {
|
||||
const files = [
|
||||
'website/components/ops/support-report-modal.tsx',
|
||||
'website/package.json',
|
||||
'website/tests/support-report-modal.test.tsx',
|
||||
'website/app/page.tsx'
|
||||
];
|
||||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, false);
|
||||
assert.strictEqual(result.app_changed, false);
|
||||
assert.strictEqual(result.appFiles.length, 0);
|
||||
assert.strictEqual(result.ciFiles.length, 0);
|
||||
assert.strictEqual(result.unrelatedFiles.length, 4);
|
||||
});
|
||||
|
||||
it('classifies backend-only changes as false', () => {
|
||||
const files = [
|
||||
'backend/motorsignal_api/app/main.py',
|
||||
'backend/motorsignal_api/app/models.py',
|
||||
'backend/motorsignal_api/tests/test_ops_actions.py',
|
||||
'backend/motorsignal_api/alembic/versions/f1a2b3c4d5e7_add_ops_actions.py'
|
||||
];
|
||||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, false);
|
||||
assert.strictEqual(result.app_changed, false);
|
||||
assert.strictEqual(result.appFiles.length, 0);
|
||||
assert.strictEqual(result.ciFiles.length, 0);
|
||||
assert.strictEqual(result.unrelatedFiles.length, 4);
|
||||
});
|
||||
|
||||
it('classifies docs-only changes as false', () => {
|
||||
const files = [
|
||||
'README.md',
|
||||
'docs/agents/issue-tracker.md',
|
||||
'AGENTS.md',
|
||||
'CHANGELOG.md',
|
||||
'DESIGN.md'
|
||||
];
|
||||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, false);
|
||||
assert.strictEqual(result.app_changed, false);
|
||||
assert.strictEqual(result.appFiles.length, 0);
|
||||
assert.strictEqual(result.ciFiles.length, 0);
|
||||
assert.strictEqual(result.unrelatedFiles.length, 5);
|
||||
});
|
||||
|
||||
it('classifies Firebase-rules-only and Waze-relay-only changes as false', () => {
|
||||
const files = [
|
||||
'firebase/firestore.rules',
|
||||
'firebase.json',
|
||||
'server/waze_relay/index.js',
|
||||
'server/waze_relay/package.json'
|
||||
];
|
||||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, false);
|
||||
assert.strictEqual(result.app_changed, false);
|
||||
});
|
||||
|
||||
it('classifies Flutter lib change as true', () => {
|
||||
const files = ['lib/telemetry/telemetry_service.dart'];
|
||||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
assert.deepStrictEqual(result.appFiles, ['lib/telemetry/telemetry_service.dart']);
|
||||
});
|
||||
|
||||
it('classifies Flutter test change as true', () => {
|
||||
const files = ['test/unit/telemetry_test.dart'];
|
||||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
assert.deepStrictEqual(result.appFiles, ['test/unit/telemetry_test.dart']);
|
||||
});
|
||||
|
||||
it('classifies Flutter integration_test change as true', () => {
|
||||
const files = ['integration_test/telemetry_soak_test.dart'];
|
||||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
});
|
||||
|
||||
it('classifies Android Gradle change as true', () => {
|
||||
const files = ['android/app/build.gradle'];
|
||||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
});
|
||||
|
||||
it('classifies pubspec change as true', () => {
|
||||
const files = ['pubspec.yaml'];
|
||||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
|
||||
const lockFiles = ['pubspec.lock'];
|
||||
const lockResult = classifyFiles(lockFiles);
|
||||
assert.strictEqual(lockResult.android_changed, true);
|
||||
assert.strictEqual(lockResult.app_changed, true);
|
||||
});
|
||||
|
||||
it('classifies analysis_options and build.yaml changes as true', () => {
|
||||
assert.strictEqual(classifyFiles(['analysis_options.yaml']).android_changed, true);
|
||||
assert.strictEqual(classifyFiles(['build.yaml']).android_changed, true);
|
||||
});
|
||||
|
||||
it('classifies assets change as true', () => {
|
||||
const files = ['assets/telemetry/sample.json'];
|
||||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
});
|
||||
|
||||
it('classifies mixed website + Flutter change as true', () => {
|
||||
const files = [
|
||||
'website/app/page.tsx',
|
||||
'website/package.json',
|
||||
'lib/models/ride.dart'
|
||||
];
|
||||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
assert.strictEqual(result.appFiles.length, 1);
|
||||
assert.strictEqual(result.unrelatedFiles.length, 2);
|
||||
});
|
||||
|
||||
it('classifies Android workflow change as android_changed=true but app_changed=false', () => {
|
||||
const files = ['.github/workflows/android-build.yml'];
|
||||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, false);
|
||||
assert.strictEqual(result.ciFiles.length, 1);
|
||||
assert.strictEqual(result.appFiles.length, 0);
|
||||
});
|
||||
|
||||
it('classifies detector script change as android_changed=true but app_changed=false', () => {
|
||||
const files = [
|
||||
'.github/scripts/detect-android-changes.cjs',
|
||||
'.github/scripts/detect-android-changes.test.cjs'
|
||||
];
|
||||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, false);
|
||||
assert.strictEqual(result.ciFiles.length, 2);
|
||||
assert.strictEqual(result.appFiles.length, 0);
|
||||
});
|
||||
|
||||
it('classifies other workflow changes (website/backend CI) as false', () => {
|
||||
const files = [
|
||||
'.github/workflows/website-ci.yml',
|
||||
'.github/workflows/backend-ci.yml',
|
||||
'.github/workflows/waze-relay-ci.yml',
|
||||
'.github/workflows/firebase-emulator-tests.yml'
|
||||
];
|
||||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, false);
|
||||
assert.strictEqual(result.app_changed, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectAndroidChanges Workflow Event Logic', () => {
|
||||
const sampleBase = '03446911949ff8b27543280bfe226d9babd6fd7a';
|
||||
const sampleHead = '247ccde71e4378dacd45121c891f45aad5ed2424';
|
||||
|
||||
it('always triggers full build on workflow_dispatch', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'workflow_dispatch',
|
||||
baseSha: '',
|
||||
headSha: ''
|
||||
});
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
assert.match(result.reason, /Manual workflow_dispatch/);
|
||||
});
|
||||
|
||||
it('fails safe to true on zero push base SHA', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha: '0000000000000000000000000000000000000000',
|
||||
headSha: sampleHead
|
||||
});
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
assert.match(result.reason, /failing safe/);
|
||||
});
|
||||
|
||||
it('fails safe to true on missing base SHA', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'pull_request',
|
||||
baseSha: '',
|
||||
headSha: sampleHead
|
||||
});
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
});
|
||||
|
||||
it('fails safe to true on invalid base SHA format', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'pull_request',
|
||||
baseSha: 'malicious-string; rm -rf /',
|
||||
headSha: sampleHead
|
||||
});
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
});
|
||||
|
||||
it('fails safe to true on invalid head SHA format', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'pull_request',
|
||||
baseSha: sampleBase,
|
||||
headSha: 'bad-sha'
|
||||
});
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
});
|
||||
|
||||
it('reports false when base and head SHA are identical', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'pull_request',
|
||||
baseSha: sampleBase,
|
||||
headSha: sampleBase
|
||||
});
|
||||
assert.strictEqual(result.android_changed, false);
|
||||
assert.strictEqual(result.app_changed, false);
|
||||
assert.match(result.reason, /identical/);
|
||||
});
|
||||
|
||||
it('fails safe to true when git diff comparison throws an error', () => {
|
||||
const faultyDeps = {
|
||||
getChangedFilesFn: () => {
|
||||
throw new Error('fatal: bad object missing-commit');
|
||||
}
|
||||
};
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'pull_request',
|
||||
baseSha: sampleBase,
|
||||
headSha: sampleHead
|
||||
}, faultyDeps);
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
assert.match(result.reason, /failing safe/);
|
||||
});
|
||||
|
||||
it('returns false for pull_request with only website files', () => {
|
||||
const mockDeps = {
|
||||
getChangedFilesFn: () => ['website/app/page.tsx', 'website/package.json']
|
||||
};
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'pull_request',
|
||||
baseSha: sampleBase,
|
||||
headSha: sampleHead
|
||||
}, mockDeps);
|
||||
assert.strictEqual(result.android_changed, false);
|
||||
assert.strictEqual(result.app_changed, false);
|
||||
});
|
||||
|
||||
it('returns true for pull_request with Flutter lib file', () => {
|
||||
const mockDeps = {
|
||||
getChangedFilesFn: () => ['lib/main.dart']
|
||||
};
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'pull_request',
|
||||
baseSha: sampleBase,
|
||||
headSha: sampleHead
|
||||
}, mockDeps);
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
});
|
||||
|
||||
it('returns android_changed=true, app_changed=false for workflow change', () => {
|
||||
const mockDeps = {
|
||||
getChangedFilesFn: () => ['.github/workflows/android-build.yml']
|
||||
};
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha: sampleBase,
|
||||
headSha: sampleHead
|
||||
}, mockDeps);
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Live Git Repository Verification', () => {
|
||||
it('correctly compares historical non-Android commit in the repository', () => {
|
||||
// 03446911949ff8b27543280bfe226d9babd6fd7a was the PR #15 merge (website and backend changes only).
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha: '7efd29a214d01bd1c167fabf75f5c90cea5da215',
|
||||
headSha: '03446911949ff8b27543280bfe226d9babd6fd7a'
|
||||
});
|
||||
|
||||
assert.strictEqual(result.android_changed, false);
|
||||
assert.strictEqual(result.app_changed, false);
|
||||
assert.strictEqual(result.appFiles.length, 0);
|
||||
assert.strictEqual(result.ciFiles.length, 0);
|
||||
assert.ok(result.unrelatedFiles.length > 0);
|
||||
});
|
||||
|
||||
it('correctly compares historical Flutter commit in the repository', () => {
|
||||
// 68d4539f90199e2428fafbb7de779ff2dfb22b4e modified Flutter lib/ and test/ files.
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha: 'e784c4895dcbc6abbfaa516ac34f3cedbc54384a',
|
||||
headSha: '68d4539f90199e2428fafbb7de779ff2dfb22b4e'
|
||||
});
|
||||
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
assert.ok(result.appFiles.length > 0);
|
||||
});
|
||||
|
||||
it('deterministically classifies workflow-only changes in a temporary repository fixture', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'detector-fixture-workflow-'));
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: tmpDir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.name', 'CI Fixture Test'], { cwd: tmpDir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.email', 'fixture@example.com'], { cwd: tmpDir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'commit.gpgSign', 'false'], { cwd: tmpDir, stdio: 'ignore' });
|
||||
|
||||
// Base commit: unrelated website file
|
||||
fs.mkdirSync(path.join(tmpDir, 'website'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, 'website', 'page.tsx'), 'export default function Page() {}\n', 'utf8');
|
||||
execFileSync('git', ['add', 'website/page.tsx'], { cwd: tmpDir, stdio: 'ignore' });
|
||||
execFileSync('git', ['commit', '-m', 'Add website page'], { cwd: tmpDir, stdio: 'ignore' });
|
||||
const baseSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, encoding: 'utf8' }).trim();
|
||||
|
||||
// Head commit: .github/workflows/android-build.yml
|
||||
fs.mkdirSync(path.join(tmpDir, '.github', 'workflows'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, '.github', 'workflows', 'android-build.yml'), 'name: MotorSignal Android CI\n', 'utf8');
|
||||
execFileSync('git', ['add', '.github/workflows/android-build.yml'], { cwd: tmpDir, stdio: 'ignore' });
|
||||
execFileSync('git', ['commit', '-m', 'Add android-build workflow'], { cwd: tmpDir, stdio: 'ignore' });
|
||||
const headSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, encoding: 'utf8' }).trim();
|
||||
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha,
|
||||
repoDir: tmpDir
|
||||
});
|
||||
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, false);
|
||||
assert.ok(result.ciFiles.includes('.github/workflows/android-build.yml'));
|
||||
assert.strictEqual(result.appFiles.length, 0);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('deterministically classifies mixed workflow and application changes in a temporary repository fixture', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'detector-fixture-mixed-'));
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: tmpDir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.name', 'CI Fixture Test'], { cwd: tmpDir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.email', 'fixture@example.com'], { cwd: tmpDir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'commit.gpgSign', 'false'], { cwd: tmpDir, stdio: 'ignore' });
|
||||
|
||||
// Base commit: unrelated website file
|
||||
fs.mkdirSync(path.join(tmpDir, 'website'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, 'website', 'page.tsx'), 'export default function Page() {}\n', 'utf8');
|
||||
execFileSync('git', ['add', 'website/page.tsx'], { cwd: tmpDir, stdio: 'ignore' });
|
||||
execFileSync('git', ['commit', '-m', 'Add website page'], { cwd: tmpDir, stdio: 'ignore' });
|
||||
const baseSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, encoding: 'utf8' }).trim();
|
||||
|
||||
// Head commit: both .github/workflows/android-build.yml and lib/main.dart
|
||||
fs.mkdirSync(path.join(tmpDir, '.github', 'workflows'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, '.github', 'workflows', 'android-build.yml'), 'name: MotorSignal Android CI\n', 'utf8');
|
||||
fs.mkdirSync(path.join(tmpDir, 'lib'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, 'lib', 'main.dart'), 'void main() {}\n', 'utf8');
|
||||
execFileSync('git', ['add', '.github/workflows/android-build.yml', 'lib/main.dart'], { cwd: tmpDir, stdio: 'ignore' });
|
||||
execFileSync('git', ['commit', '-m', 'Add workflow and Flutter app code'], { cwd: tmpDir, stdio: 'ignore' });
|
||||
const headSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, encoding: 'utf8' }).trim();
|
||||
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'pull_request',
|
||||
baseSha,
|
||||
headSha,
|
||||
repoDir: tmpDir
|
||||
});
|
||||
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
assert.ok(result.ciFiles.includes('.github/workflows/android-build.yml'));
|
||||
assert.ok(result.appFiles.includes('lib/main.dart'));
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workflow Scenario Matrix Verification', () => {
|
||||
const baseSha = '03446911949ff8b27543280bfe226d9babd6fd7a';
|
||||
const headSha = '247ccde71e4378dacd45121c891f45aad5ed2424';
|
||||
|
||||
it('website-only PR skips heavy build and release', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'pull_request',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['website/app/page.tsx', 'website/package.json'] });
|
||||
|
||||
const heavyBuildRuns = result.android_changed;
|
||||
const releaseRuns = false; // PR event cannot run release
|
||||
assert.strictEqual(result.android_changed, false);
|
||||
assert.strictEqual(result.app_changed, false);
|
||||
assert.strictEqual(heavyBuildRuns, false);
|
||||
assert.strictEqual(releaseRuns, false);
|
||||
});
|
||||
|
||||
it('backend-only PR skips heavy build and release', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'pull_request',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['backend/motorsignal_api/app/main.py'] });
|
||||
|
||||
assert.strictEqual(result.android_changed, false);
|
||||
assert.strictEqual(result.app_changed, false);
|
||||
});
|
||||
|
||||
it('Flutter PR runs heavy build and skips release', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'pull_request',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['lib/main.dart', 'test/widget_test.dart'] });
|
||||
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
});
|
||||
|
||||
it('website + Flutter PR runs heavy build and skips release', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'pull_request',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['website/app/page.tsx', 'lib/services/telemetry.dart'] });
|
||||
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
});
|
||||
|
||||
it('master push after website merge skips heavy build and release', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['website/components/ops/support-report-modal.tsx'] });
|
||||
|
||||
assert.strictEqual(result.android_changed, false);
|
||||
assert.strictEqual(result.app_changed, false);
|
||||
});
|
||||
|
||||
it('master push after Flutter merge runs heavy build and signed release', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['lib/telemetry/telemetry_service.dart'] });
|
||||
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
});
|
||||
|
||||
it('master push after workflow-only change runs heavy build but skips signed release', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['.github/workflows/android-build.yml'] });
|
||||
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, false);
|
||||
});
|
||||
|
||||
it('manual dispatch runs heavy build and signed release', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'workflow_dispatch',
|
||||
baseSha: '',
|
||||
headSha: ''
|
||||
});
|
||||
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -8,7 +8,7 @@ on:
|
|||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: android-build-${{ github.ref }}
|
||||
|
|
@ -22,9 +22,40 @@ env:
|
|||
GRADLE_VERSION: '9.1.0'
|
||||
|
||||
jobs:
|
||||
detect:
|
||||
name: Detect Android changes
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
android_changed: ${{ steps.detector.outputs.android_changed }}
|
||||
app_changed: ${{ steps.detector.outputs.app_changed }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Run detector tests
|
||||
run: node --test .github/scripts/detect-android-changes.test.cjs
|
||||
- name: Detect changes
|
||||
id: detector
|
||||
env:
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: node .github/scripts/detect-android-changes.cjs
|
||||
|
||||
analyze-and-build:
|
||||
name: Analyze, test and debug build
|
||||
runs-on: ubuntu-latest
|
||||
needs: detect
|
||||
if: github.event_name == 'workflow_dispatch' || needs.detect.outputs.android_changed == 'true'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
|
@ -67,9 +98,25 @@ jobs:
|
|||
SDKMANAGER="$(find "$SDKROOT/cmdline-tools" -type f -name sdkmanager -print -quit 2>/dev/null || true)"
|
||||
test -x "$SDKMANAGER"
|
||||
yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true
|
||||
"$SDKMANAGER" --list | grep -F "platforms;android-${ANDROID_COMPILE_SDK}" >/dev/null
|
||||
"$SDKMANAGER" "platform-tools" "platforms;android-${ANDROID_COMPILE_SDK}" "build-tools;${ANDROID_BUILD_TOOLS}"
|
||||
yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true
|
||||
|
||||
MISSING_COMPONENTS=()
|
||||
if [ ! -d "$SDKROOT/platform-tools" ]; then
|
||||
MISSING_COMPONENTS+=("platform-tools")
|
||||
fi
|
||||
if [ ! -d "$SDKROOT/platforms/android-${ANDROID_COMPILE_SDK}" ]; then
|
||||
MISSING_COMPONENTS+=("platforms;android-${ANDROID_COMPILE_SDK}")
|
||||
fi
|
||||
if [ ! -d "$SDKROOT/build-tools/${ANDROID_BUILD_TOOLS}" ]; then
|
||||
MISSING_COMPONENTS+=("build-tools;${ANDROID_BUILD_TOOLS}")
|
||||
fi
|
||||
|
||||
if [ ${#MISSING_COMPONENTS[@]} -gt 0 ]; then
|
||||
echo "Installing missing Android SDK components: ${MISSING_COMPONENTS[*]}"
|
||||
"$SDKMANAGER" "${MISSING_COMPONENTS[@]}"
|
||||
yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true
|
||||
else
|
||||
echo "Required Android SDK components already present."
|
||||
fi
|
||||
echo "ANDROID_SDK_ROOT=$SDKROOT" >> "$GITHUB_ENV"
|
||||
- name: Provision Gradle wrapper
|
||||
shell: bash
|
||||
|
|
@ -129,8 +176,10 @@ jobs:
|
|||
release:
|
||||
name: Build signed release APK and AAB
|
||||
runs-on: ubuntu-latest
|
||||
needs: analyze-and-build
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||
needs: [detect, analyze-and-build]
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.detect.outputs.app_changed == 'true')
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
|
@ -170,9 +219,26 @@ jobs:
|
|||
SDKMANAGER="$(find "$SDKROOT/cmdline-tools" -type f -name sdkmanager -print -quit 2>/dev/null || true)"
|
||||
test -x "$SDKMANAGER"
|
||||
yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true
|
||||
"$SDKMANAGER" --list | grep -F "platforms;android-${ANDROID_COMPILE_SDK}" >/dev/null
|
||||
"$SDKMANAGER" "platform-tools" "platforms;android-${ANDROID_COMPILE_SDK}" "build-tools;${ANDROID_BUILD_TOOLS}"
|
||||
yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true
|
||||
|
||||
MISSING_COMPONENTS=()
|
||||
if [ ! -d "$SDKROOT/platform-tools" ]; then
|
||||
MISSING_COMPONENTS+=("platform-tools")
|
||||
fi
|
||||
if [ ! -d "$SDKROOT/platforms/android-${ANDROID_COMPILE_SDK}" ]; then
|
||||
MISSING_COMPONENTS+=("platforms;android-${ANDROID_COMPILE_SDK}")
|
||||
fi
|
||||
if [ ! -d "$SDKROOT/build-tools/${ANDROID_BUILD_TOOLS}" ]; then
|
||||
MISSING_COMPONENTS+=("build-tools;${ANDROID_BUILD_TOOLS}")
|
||||
fi
|
||||
|
||||
if [ ${#MISSING_COMPONENTS[@]} -gt 0 ]; then
|
||||
echo "Installing missing Android SDK components: ${MISSING_COMPONENTS[*]}"
|
||||
"$SDKMANAGER" "${MISSING_COMPONENTS[@]}"
|
||||
yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true
|
||||
else
|
||||
echo "Required Android SDK components already present."
|
||||
fi
|
||||
echo "ANDROID_SDK_ROOT=$SDKROOT" >> "$GITHUB_ENV"
|
||||
- name: Provision Gradle wrapper
|
||||
shell: bash
|
||||
run: |
|
||||
|
|
|
|||
Loading…
Reference in New Issue