Poll OTA Repository for Updates #8413
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Poll OTA Repository for Updates | |
| on: | |
| schedule: | |
| # Run every 15 minutes instead of 6 hours | |
| - cron: '*/15 * * * *' | |
| workflow_dispatch: | |
| inputs: | |
| force_update: | |
| description: 'Force update even if no changes detected' | |
| required: false | |
| default: false | |
| type: boolean | |
| check_interval: | |
| description: 'Check for changes in the last N minutes' | |
| required: false | |
| default: '30' | |
| type: string | |
| jobs: | |
| poll-and-update: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| persist-credentials: true | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v3 | |
| with: | |
| node-version: '18' | |
| - name: Check for OTA repository changes | |
| id: check_changes | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| FORCE_UPDATE: ${{ github.event.inputs.force_update || 'false' }} | |
| CHECK_INTERVAL: ${{ github.event.inputs.check_interval || '30' }} | |
| run: | | |
| # write a JS script to check for recent changes | |
| cat > check-changes.js <<'JS' | |
| const https = require('https'); | |
| const fs = require('fs'); | |
| const OTA_REPO = 'AlphaDroid-devices/OTA'; | |
| const CHECK_INTERVAL = parseInt(process.env.CHECK_INTERVAL) || 30; // minutes | |
| const FORCE_UPDATE = process.env.FORCE_UPDATE === 'true'; | |
| const TOKEN = process.env.GITHUB_TOKEN; | |
| const opts = { | |
| headers: { | |
| 'User-Agent': 'AlphaDroid-Poll-Bot/1.0', | |
| 'Accept': 'application/vnd.github.v3+json', | |
| 'Authorization': `token ${TOKEN}` | |
| } | |
| }; | |
| function apiRequest(url) { | |
| return new Promise((resolve, reject) => { | |
| https.get(url, opts, (response) => { | |
| let data = ''; | |
| response.on('data', chunk => data += chunk); | |
| response.on('end', () => { | |
| if (response.statusCode >= 200 && response.statusCode < 300) { | |
| resolve(JSON.parse(data)); | |
| } else { | |
| reject(new Error(`API request failed: ${response.statusCode}`)); | |
| } | |
| }); | |
| }).on('error', reject); | |
| }); | |
| } | |
| async function checkForChanges() { | |
| try { | |
| console.log(`Checking for changes in the last ${CHECK_INTERVAL} minutes...`); | |
| // Get recent commits from OTA repository | |
| const commitsUrl = `https://api.github.com/repos/${OTA_REPO}/commits?since=${new Date(Date.now() - CHECK_INTERVAL * 60 * 1000).toISOString()}`; | |
| const commits = await apiRequest(commitsUrl); | |
| console.log(`Found ${commits.length} recent commits`); | |
| if (commits.length === 0 && !FORCE_UPDATE) { | |
| console.log('No recent commits found, skipping update'); | |
| process.exit(0); | |
| } | |
| // Check if any commits modified JSON files | |
| let hasJsonChanges = false; | |
| if (commits.length > 0) { | |
| for (const commit of commits.slice(0, 5)) { // Check last 5 commits | |
| try { | |
| const commitDetails = await apiRequest(commit.url); | |
| const files = commitDetails.files || []; | |
| const jsonFiles = files.filter(file => | |
| file.filename.toLowerCase().endsWith('.json') && | |
| (file.status === 'modified' || file.status === 'added') | |
| ); | |
| if (jsonFiles.length > 0) { | |
| console.log(`Found JSON changes in commit ${commit.sha}: ${jsonFiles.map(f => f.filename).join(', ')}`); | |
| hasJsonChanges = true; | |
| break; | |
| } | |
| } catch (error) { | |
| console.warn(`Failed to get details for commit ${commit.sha}:`, error.message); | |
| } | |
| } | |
| } | |
| if (!hasJsonChanges && !FORCE_UPDATE) { | |
| console.log('No JSON file changes detected in recent commits'); | |
| process.exit(0); | |
| } | |
| console.log('JSON file changes detected or force update requested, proceeding with update'); | |
| // Write trigger info for next step | |
| fs.writeFileSync('update-trigger.json', JSON.stringify({ | |
| hasChanges: true, | |
| commitCount: commits.length, | |
| hasJsonChanges, | |
| forceUpdate: FORCE_UPDATE, | |
| checkedAt: new Date().toISOString(), | |
| checkInterval: CHECK_INTERVAL | |
| })); | |
| } catch (error) { | |
| console.error('Error checking for changes:', error.message); | |
| if (FORCE_UPDATE) { | |
| console.log('Force update requested, proceeding despite error'); | |
| fs.writeFileSync('update-trigger.json', JSON.stringify({ | |
| hasChanges: true, | |
| error: error.message, | |
| forceUpdate: true, | |
| checkedAt: new Date().toISOString() | |
| })); | |
| } else { | |
| process.exit(1); | |
| } | |
| } | |
| } | |
| checkForChanges(); | |
| JS | |
| node check-changes.js | |
| - name: Fetch and update device data | |
| if: steps.check_changes.outcome == 'success' | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TRIGGER_REASON: ${{ github.event_name == 'workflow_dispatch' && 'manual_poll' || 'scheduled_poll' }} | |
| run: | | |
| # Use the same enhanced fetch script from the main workflow | |
| mkdir -p .github/scripts | |
| cat > .github/scripts/fetch-devices.js <<'JS' | |
| const https = require('https'); | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const API_BASE = 'https://api.github.com/repos/AlphaDroid-devices/OTA'; | |
| const API_CONTENTS = `${API_BASE}/contents/`; | |
| const TOKEN = process.env.GITHUB_TOKEN; | |
| const TRIGGER_REASON = process.env.TRIGGER_REASON || 'unknown'; | |
| const opts = { | |
| headers: { | |
| 'User-Agent': 'AlphaDroid-Website-Bot/1.0', | |
| 'Accept': 'application/vnd.github.v3+json', | |
| ...(TOKEN ? { 'Authorization': `token ${TOKEN}` } : {}) | |
| } | |
| }; | |
| function get(url, retries = 3) { | |
| return new Promise((resolve, reject) => { | |
| const attempt = (retryCount) => { | |
| const request = https.get(url, opts, response => { | |
| let body = ''; | |
| response.on('data', chunk => body += chunk); | |
| response.on('end', () => { | |
| if (response.statusCode >= 200 && response.statusCode < 300) { | |
| resolve({ | |
| statusCode: response.statusCode, | |
| headers: response.headers, | |
| body, | |
| url | |
| }); | |
| } else if (response.statusCode === 403 && retryCount > 0) { | |
| console.warn(`Rate limited, retrying in ${(4 - retryCount) * 2} seconds... (${retryCount} retries left)`); | |
| setTimeout(() => attempt(retryCount - 1), (4 - retryCount) * 2000); | |
| } else { | |
| reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage} for ${url}`)); | |
| } | |
| }); | |
| }); | |
| request.on('error', error => { | |
| if (retryCount > 0) { | |
| console.warn(`Network error, retrying... (${retryCount} retries left)`, error.message); | |
| setTimeout(() => attempt(retryCount - 1), 1000); | |
| } else { | |
| reject(error); | |
| } | |
| }); | |
| request.setTimeout(30000, () => { | |
| request.destroy(); | |
| if (retryCount > 0) { | |
| console.warn(`Timeout, retrying... (${retryCount} retries left)`); | |
| setTimeout(() => attempt(retryCount - 1), 1000); | |
| } else { | |
| reject(new Error('Request timeout')); | |
| } | |
| }); | |
| }; | |
| attempt(retries); | |
| }); | |
| } | |
| async function fetchDeviceFiles() { | |
| console.log(`Starting device fetch (trigger: ${TRIGGER_REASON})`); | |
| const startTime = Date.now(); | |
| try { | |
| console.log('Fetching repository contents...'); | |
| const listResp = await get(API_CONTENTS); | |
| const items = JSON.parse(listResp.body) | |
| .filter(item => item.type === 'file' && item.name.toLowerCase().endsWith('.json')) | |
| .sort((a, b) => a.name.localeCompare(b.name)); | |
| console.log(`Found ${items.length} JSON files to process`); | |
| if (items.length === 0) { | |
| console.warn('No JSON files found in repository'); | |
| return { success: false, error: 'No JSON files found' }; | |
| } | |
| const results = []; | |
| let successCount = 0; | |
| let errorCount = 0; | |
| const batchSize = 5; | |
| for (let i = 0; i < items.length; i += batchSize) { | |
| const batch = items.slice(i, i + batchSize); | |
| console.log(`Processing batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(items.length / batchSize)} (${batch.length} files)`); | |
| const batchPromises = batch.map(async (item) => { | |
| try { | |
| const response = await get(item.download_url); | |
| const data = JSON.parse(response.body); | |
| if (!data || typeof data !== 'object') { | |
| throw new Error('Invalid JSON structure'); | |
| } | |
| results.push({ | |
| name: item.name, | |
| data, | |
| rawUrl: item.download_url, | |
| lastModified: response.headers['last-modified'] || null, | |
| size: response.headers['content-length'] || null, | |
| sha: item.sha | |
| }); | |
| successCount++; | |
| console.log(`✓ ${item.name}`); | |
| } catch (error) { | |
| errorCount++; | |
| console.error(`✗ ${item.name}: ${error.message}`); | |
| } | |
| }); | |
| await Promise.all(batchPromises); | |
| if (i + batchSize < items.length) { | |
| await new Promise(resolve => setTimeout(resolve, 100)); | |
| } | |
| } | |
| if (results.length === 0) { | |
| console.error('No device files were successfully fetched'); | |
| return { success: false, error: 'All fetches failed' }; | |
| } | |
| const dataDir = 'data'; | |
| if (!fs.existsSync(dataDir)) { | |
| fs.mkdirSync(dataDir, { recursive: true }); | |
| } | |
| const outputPath = path.join(dataDir, 'devices.json'); | |
| const outputData = { | |
| metadata: { | |
| fetchedAt: new Date().toISOString(), | |
| trigger: TRIGGER_REASON, | |
| totalFiles: items.length, | |
| successfulFetches: successCount, | |
| failedFetches: errorCount, | |
| fetchDurationMs: Date.now() - startTime | |
| }, | |
| devices: results | |
| }; | |
| // Before writing, compare only the device-level content with existing file | |
| let shouldWrite = true; | |
| try { | |
| if (fs.existsSync(outputPath)) { | |
| const existing = fs.readFileSync(outputPath, 'utf8'); | |
| const existingJson = JSON.parse(existing); | |
| // Normalize devices to only include name and data, and sort by name | |
| const normalize = (devices) => ( | |
| devices | |
| .map(d => ({ name: d.name, data: d.data })) | |
| .sort((a, b) => a.name.localeCompare(b.name)) | |
| ); | |
| const existingNormalized = normalize(existingJson.devices || []); | |
| const outputNormalized = normalize(outputData.devices || []); | |
| if (JSON.stringify(existingNormalized) === JSON.stringify(outputNormalized)) { | |
| console.log('No device-level changes detected after normalizing; skipping write.'); | |
| shouldWrite = false; | |
| } | |
| } | |
| } catch (err) { | |
| console.warn('Failed to compare with existing devices.json:', err.message); | |
| shouldWrite = true; | |
| } | |
| const duration = Date.now() - startTime; | |
| if (shouldWrite) { | |
| fs.writeFileSync(outputPath, JSON.stringify(outputData, null, 2)); | |
| console.log(`\n✓ Successfully wrote ${outputPath}`); | |
| console.log(` Devices: ${results.length}/${items.length}`); | |
| console.log(` Duration: ${duration}ms`); | |
| console.log(` Success rate: ${((successCount / items.length) * 100).toFixed(1)}%`); | |
| return { | |
| success: true, | |
| deviceCount: results.length, | |
| totalFiles: items.length, | |
| successRate: (successCount / items.length) * 100, | |
| duration | |
| }; | |
| } else { | |
| console.log(`Skipped writing ${outputPath} — device data unchanged.`); | |
| return { | |
| success: true, | |
| skipped: true, | |
| reason: 'No device-level changes', | |
| deviceCount: results.length, | |
| totalFiles: items.length, | |
| duration | |
| }; | |
| } | |
| } catch (error) { | |
| console.error('Fatal error during fetch:', error.message); | |
| return { success: false, error: error.message }; | |
| } | |
| } | |
| fetchDeviceFiles() | |
| .then(result => { | |
| if (result.success) { | |
| console.log('Device fetch completed successfully'); | |
| process.exit(0); | |
| } else { | |
| console.error('Device fetch failed:', result.error); | |
| process.exit(1); | |
| } | |
| }) | |
| .catch(error => { | |
| console.error('Unexpected error:', error); | |
| process.exit(1); | |
| }); | |
| JS | |
| echo "Trigger reason: $TRIGGER_REASON" | |
| node .github/scripts/fetch-devices.js | |
| - name: Commit and push changes | |
| if: steps.check_changes.outcome == 'success' | |
| id: commit | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| # Stage only the target file | |
| git add --force data/devices.json || true | |
| # Check if the staged file differs from HEAD specifically | |
| if git diff --quiet HEAD -- data/devices.json; then | |
| echo "No changes to commit (data/devices.json unchanged)" | |
| echo "has_changes=false" >> $GITHUB_OUTPUT | |
| else | |
| TRIGGER="${{ github.event_name == 'workflow_dispatch' && 'manual_poll' || 'scheduled_poll' }}" | |
| if [[ "$TRIGGER" == "manual_poll" ]]; then | |
| COMMIT_MSG="chore: manual poll update of device data [skip ci]" | |
| else | |
| COMMIT_MSG="chore: scheduled poll update of device data [skip ci]" | |
| fi | |
| git commit -m "$COMMIT_MSG" | |
| git push | |
| echo "has_changes=true" >> $GITHUB_OUTPUT | |
| echo "Changes committed and pushed successfully" | |
| fi | |
| - name: Create summary | |
| if: always() | |
| run: | | |
| echo "## OTA Repository Poll Update Summary" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "**Trigger:** ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY | |
| echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY | |
| echo "**Changes Committed:** ${{ steps.commit.outputs.has_changes || 'N/A' }}" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| if [[ "${{ job.status }}" == "success" ]]; then | |
| echo "✅ **Success:** OTA repository poll completed successfully" >> $GITHUB_STEP_SUMMARY | |
| else | |
| echo "❌ **Failed:** OTA repository poll failed" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "### Poll Details" >> $GITHUB_STEP_SUMMARY | |
| echo "- **Repository:** AlphaDroid-devices/OTA" >> $GITHUB_STEP_SUMMARY | |
| echo "- **Poll Interval:** Every 15 minutes" >> $GITHUB_STEP_SUMMARY | |
| echo "- **Target File:** data/devices.json" >> $GITHUB_STEP_SUMMARY | |
| echo "- **Workflow Run:** [View Details](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> $GITHUB_STEP_SUMMARY |