Skip to content

Poll OTA Repository for Updates #8413

Poll OTA Repository for Updates

Poll OTA Repository for Updates #8413

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