From 132d37834683dd56838c9c5a0cb8a4fca9f4649f Mon Sep 17 00:00:00 2001 From: Gauvain Date: Tue, 16 Jun 2026 19:18:14 +0200 Subject: [PATCH] ci(artifact-comment): always-on dropdown, build ETA, signed/unsigned fix The PR build-status comment had several issues: - The "Build details & device compatibility" dropdown only rendered once artifacts existed, so it was missing for the whole build (the most useful time to read it). Always render it now. - In-progress / queued targets showed an open-ended spinner with no time estimate. Pull per-job durations from the latest successful develop build and surface them as an ETA (best-effort; dropped on any failure). - Signed iOS/tvOS job status could be read from the "(Unsigned)" job: `.find` + `.includes` matched the unsigned name (which contains the signed name as a substring). Prefer an exact name match. - Signed iOS/tvOS artifact pattern `ios.*phone.*ipa(?!.*unsigned)` also matched the unsigned artifact, because "unsigned" precedes "ipa" in the artifact names. Anchor a negative lookahead so "unsigned" is excluded anywhere in the name. Also drop a misleading "non-cancelled" log line (the filter keeps cancelled runs) and factor out a shared duration formatter. --- .github/workflows/artifact-comment.yml | 132 +++++++++++++++++-------- 1 file changed, 93 insertions(+), 39 deletions(-) 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`; }