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:
Billie Ponder 2026-09-21 02:02:32 -04:00 committed by GitHub
parent 0344691194
commit d69d6119d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 908 additions and 9 deletions

View File

@ -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
};

View File

@ -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);
});
});

View File

@ -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: |