diff --git a/.github/scripts/detect-android-changes.cjs b/.github/scripts/detect-android-changes.cjs new file mode 100644 index 0000000..2353d01 --- /dev/null +++ b/.github/scripts/detect-android-changes.cjs @@ -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 +}; diff --git a/.github/scripts/detect-android-changes.test.cjs b/.github/scripts/detect-android-changes.test.cjs new file mode 100644 index 0000000..fff69f5 --- /dev/null +++ b/.github/scripts/detect-android-changes.test.cjs @@ -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); + }); +}); + diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index 37a0bf2..303e3e1 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -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: |