diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml
index b81eeeaf..80c7119e 100644
--- a/.github/workflows/artifact-comment.yml
+++ b/.github/workflows/artifact-comment.yml
@@ -144,7 +144,7 @@ jobs:
)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
- console.log(`Found ${buildRuns.length} non-cancelled build workflow runs for this commit`);
+ console.log(`Found ${buildRuns.length} build workflow runs for this commit`);
// Log current status of each build for debugging
buildRuns.forEach(run => {
@@ -184,21 +184,35 @@ jobs:
const latestAndroidRun = findBestRun('Android APK Build');
const latestIOSRun = findBestRun('iOS IPA Build');
+ // Map our build targets to their job display names. Exact name is
+ // tried first so a signed target never collides with its
+ // "(Unsigned)" sibling (whose name contains the signed name).
+ const jobMappings = {
+ 'Android Phone': ['π€ Build Android APK (Phone)'],
+ 'Android TV': ['π€ Build Android APK (TV)'],
+ 'iOS': ['π Build iOS IPA (Phone)'],
+ 'iOS Unsigned': ['π Build iOS IPA (Phone - Unsigned)'],
+ 'tvOS': ['π Build tvOS IPA'],
+ 'tvOS Unsigned': ['π Build tvOS IPA (Unsigned)']
+ };
+
+ // Prefer an exact name match over a substring match so
+ // '...(Phone)' doesn't swallow '...(Phone - Unsigned)'.
+ const findJobForTarget = (jobs, jobNames) =>
+ jobs.find(j => jobNames.some(name => j.name === name)) ||
+ jobs.find(j => jobNames.some(name => j.name.includes(name)));
+
+ // Format a millisecond duration as "Xm Ys".
+ const fmtDuration = (ms) => {
+ const min = Math.floor(ms / 60000);
+ const sec = Math.floor((ms % 60000) / 1000);
+ return `${min}m ${sec}s`;
+ };
+
// For the consolidated workflow, get individual job statuses
if (latestAppsRun) {
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
- // Map job names to our build targets. Declared outside the try so
- // the catch fallback can reuse the same keys.
- const jobMappings = {
- 'Android Phone': ['π€ Build Android APK (Phone)', 'build-android-phone'],
- 'Android TV': ['π€ Build Android APK (TV)', 'build-android-tv'],
- 'iOS': ['π Build iOS IPA (Phone)', 'build-ios-phone'],
- 'iOS Unsigned': ['π Build iOS IPA (Phone - Unsigned)', 'build-ios-phone-unsigned'],
- 'tvOS': ['π Build tvOS IPA', 'build-ios-tv'],
- 'tvOS Unsigned': ['π Build tvOS IPA (Unsigned)', 'build-ios-tv-unsigned']
- };
-
try {
// Get all jobs for this workflow run
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
@@ -229,10 +243,8 @@ jobs:
// Create individual status for each job
for (const [platform, jobNames] of Object.entries(jobMappings)) {
- const job = jobs.jobs.find(j =>
- jobNames.some(name => j.name.includes(name) || j.name === name)
- );
-
+ const job = findJobForTarget(jobs.jobs, jobNames);
+
if (job) {
buildStatuses[platform] = {
name: job.name,
@@ -358,6 +370,43 @@ jobs:
console.log(`- Artifact: ${artifact.name} (from run ${artifact.workflow_run.id})`);
});
+ // Pull per-job durations from the latest successful develop build so
+ // in-progress / queued targets can show a realistic ETA instead of
+ // an open-ended spinner. Best-effort: any failure just drops the ETA.
+ let referenceDurations = {};
+ try {
+ const { data: devRuns } = await github.rest.actions.listWorkflowRuns({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ workflow_id: 'build-apps.yml',
+ branch: 'develop',
+ status: 'success',
+ per_page: 1
+ });
+
+ if (devRuns.workflow_runs.length > 0) {
+ const refRun = devRuns.workflow_runs[0];
+ const { data: refJobs } = await github.rest.actions.listJobsForWorkflowRun({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ run_id: refRun.id
+ });
+
+ for (const [platform, jobNames] of Object.entries(jobMappings)) {
+ const job = findJobForTarget(refJobs.jobs, jobNames);
+ if (job && job.conclusion === 'success' && job.started_at && job.completed_at) {
+ referenceDurations[platform] = new Date(job.completed_at) - new Date(job.started_at);
+ }
+ }
+ console.log(`Reference durations from develop run ${refRun.id}:`,
+ Object.fromEntries(Object.entries(referenceDurations).map(([k, v]) => [k, fmtDuration(v)])));
+ } else {
+ console.log('No successful develop build found for ETA reference');
+ }
+ } catch (error) {
+ console.log('Failed to fetch develop reference durations:', error.message);
+ }
+
// Build comment body with progressive status for individual builds
let commentBody = `## π§ Build Status for PR #${pr.number}\n\n`;
commentBody += `π **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; // Progressive build status and downloads table
@@ -369,9 +418,9 @@ jobs:
const buildTargets = [
{ name: 'Android Phone', platform: 'π€', device: 'π± Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
{ name: 'Android TV', platform: 'π€', device: 'πΊ TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
- { name: 'iOS', platform: 'π', device: 'π± Phone', statusKey: 'iOS', artifactPattern: /ios.*phone.*ipa(?!.*unsigned)/i },
+ { name: 'iOS', platform: 'π', device: 'π± Phone', statusKey: 'iOS', artifactPattern: /^(?!.*unsigned).*ios.*phone.*ipa/i },
{ name: 'iOS Unsigned', platform: 'π', device: 'π± Phone Unsigned', statusKey: 'iOS Unsigned', artifactPattern: /ios.*phone.*unsigned/i },
- { name: 'tvOS', platform: 'π', device: 'πΊ TV', statusKey: 'tvOS', artifactPattern: /ios.*tv.*ipa(?!.*unsigned)/i },
+ { name: 'tvOS', platform: 'π', device: 'πΊ TV', statusKey: 'tvOS', artifactPattern: /^(?!.*unsigned).*ios.*tv.*ipa/i },
{ name: 'tvOS Unsigned', platform: 'π', device: 'πΊ TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
];
@@ -407,11 +456,9 @@ jobs:
let durationInfo = '';
if (matchingStatus.started_at && matchingStatus.completed_at) {
const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at);
- const durationMin = Math.floor(durationMs / 60000);
- const durationSec = Math.floor((durationMs % 60000) / 1000);
- durationInfo = ` - ${durationMin}m ${durationSec}s`;
+ durationInfo = ` - ${fmtDuration(durationMs)}`;
}
-
+
downloadLink = `[π₯ Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
} else if (matchingStatus.conclusion === 'failure') {
status = `β [Failed](${matchingStatus.url})`;
@@ -421,10 +468,16 @@ jobs:
downloadLink = '*Build cancelled*';
} else if (matchingStatus.status === 'in_progress') {
status = `π [Building...](${matchingStatus.url})`;
- downloadLink = '*Build in progress...*';
+ const ref = referenceDurations[target.statusKey];
+ downloadLink = ref
+ ? `*Building⦠~${fmtDuration(ref)} (avg on develop)*`
+ : '*Build in progress...*';
} else if (matchingStatus.status === 'queued') {
status = `β³ [Queued](${matchingStatus.url})`;
- downloadLink = '*Waiting to start...*';
+ const ref = referenceDurations[target.statusKey];
+ downloadLink = ref
+ ? `*Waiting to start⦠~${fmtDuration(ref)} once running (avg on develop)*`
+ : '*Waiting to start...*';
} else if (matchingStatus.status === 'completed' && !matchingStatus.conclusion) {
// Workflow completed but conclusion not yet available (rare edge case)
status = `π [Finishing...](${matchingStatus.url})`;
@@ -445,26 +498,27 @@ jobs:
commentBody += `\n`;
- // Show installation instructions if we have any artifacts
+ // Static rundown of the build optimisations + what each artifact
+ // installs on. Always shown (even mid-build) so testers know what
+ // to expect before downloads are ready.
+ commentBody += `\n`;
+ commentBody += `π¦ Build details & device compatibility
\n\n`;
+ commentBody += `These CI builds are trimmed for size and speed. What that means for installing them:\n\n`;
+ commentBody += `| Artifact | Architectures | Installs on |\n`;
+ commentBody += `|---|---|---|\n`;
+ commentBody += `| π€ Android Phone APK | \`arm64-v8a\` | Every 64-bit Android phone (all since ~2017). **Not** an x86_64 emulator or a 32-bit device. |\n`;
+ commentBody += `| πΊ Android TV APK | \`arm64-v8a\` + \`armeabi-v7a\` | Modern boxes **and** older / cheap 32-bit Android TV sticks. No x86_64. |\n`;
+ commentBody += `| π iOS / tvOS IPA | \`arm64\` | iPhone / Apple TV (all current devices). |\n\n`;
+ commentBody += `**Why no x86_64?** That slice only runs on Android emulators / Chromebooks, never a real phone or TV box β dropping it shrinks the APK and speeds up the build. Local \`bun run android\` is unaffected (it still builds x86_64 from \`app.json\`).\n\n`;
+ commentBody += `**Runners:** Android on \`ubuntu-26.04\`; iOS / tvOS on Apple Silicon (\`macos-26\`). The size/speed win comes from the ABI trim above, not the runner.\n`;
+ commentBody += ` \n\n`;
+
+ // Installation instructions only matter once something is downloadable.
if (allArtifacts.length > 0) {
commentBody += `### π§ Installation Instructions\n\n`;
commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`;
commentBody += `- **iOS IPA**: Install using [AltStore](https://altstore.io/), [Sideloadly](https://sideloadly.io/), or Xcode\n\n`;
commentBody += `> β οΈ **Note**: Artifacts expire in 7 days from build date\n\n`;
-
- // Collapsible rundown of the build optimisations + what each
- // artifact actually installs on, so testers grab the right file.
- commentBody += `\n`;
- commentBody += `π¦ Build details & device compatibility
\n\n`;
- commentBody += `These CI builds are trimmed for size and speed. What that means for installing them:\n\n`;
- commentBody += `| Artifact | Architectures | Installs on |\n`;
- commentBody += `|---|---|---|\n`;
- commentBody += `| π€ Android Phone APK | \`arm64-v8a\` | Every 64-bit Android phone (all since ~2017). **Not** an x86_64 emulator or a 32-bit device. |\n`;
- commentBody += `| πΊ Android TV APK | \`arm64-v8a\` + \`armeabi-v7a\` | Modern boxes **and** older / cheap 32-bit Android TV sticks. No x86_64. |\n`;
- commentBody += `| π iOS / tvOS IPA | \`arm64\` | iPhone / Apple TV (all current devices). |\n\n`;
- commentBody += `**Why no x86_64?** That slice only runs on Android emulators / Chromebooks, never a real phone or TV box β dropping it shrinks the APK and speeds up the build. Local \`bun run android\` is unaffected (it still builds x86_64 from \`app.json\`).\n\n`;
- commentBody += `**Runners:** Android on \`ubuntu-26.04\`; iOS / tvOS on Apple Silicon (\`macos-26\`). The size/speed win comes from the ABI trim above, not the runner.\n`;
- commentBody += ` \n\n`;
} else {
commentBody += `β³ **Builds are starting up...** This comment will update automatically as each build completes.\n\n`;
}