From bf3dc4a366e26ffd176b67d77a149c0b3b116e19 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Wed, 17 Jun 2026 09:39:43 +0200 Subject: [PATCH] ci(artifact-comment): always-on dropdown, build ETA, signed/unsigned fix (#1734) --- .github/actions/refresh-pr-comment/action.yml | 21 +++ .github/workflows/artifact-comment.yml | 132 ++++++++++++------ .github/workflows/build-apps.yml | 21 +++ 3 files changed, 135 insertions(+), 39 deletions(-) create mode 100644 .github/actions/refresh-pr-comment/action.yml diff --git a/.github/actions/refresh-pr-comment/action.yml b/.github/actions/refresh-pr-comment/action.yml new file mode 100644 index 00000000..3149ea42 --- /dev/null +++ b/.github/actions/refresh-pr-comment/action.yml @@ -0,0 +1,21 @@ +name: Refresh PR build comment +description: >- + Nudge artifact-comment.yml (via workflow_dispatch) so the PR build-status + comment reflects live per-platform progress as each build job finishes. + +runs: + using: composite + steps: + # workflow_dispatch fires even when triggered by the GITHUB_TOKEN, and + # artifact-comment's concurrency group collapses simultaneous nudges, so + # this can't spam the comment. Skipped on forks (their read-only token + # cannot dispatch). github.token is used because composite actions cannot + # read the secrets context. + - if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + continue-on-error: true + shell: bash + env: + GH_TOKEN: ${{ github.token }} + HEAD_REF: ${{ github.head_ref }} + REPO: ${{ github.repository }} + run: gh workflow run artifact-comment.yml --ref "$HEAD_REF" -R "$REPO" 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`; } diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml index f305fbfc..3a50064c 100644 --- a/.github/workflows/build-apps.yml +++ b/.github/workflows/build-apps.yml @@ -27,6 +27,7 @@ jobs: name: πŸ€– Build Android APK (Phone) permissions: contents: read + actions: write # dispatch artifact-comment.yml to refresh the PR comment steps: - name: πŸ—‘οΈ Free Disk Space @@ -117,12 +118,16 @@ jobs: android/app/build/outputs/apk/release/*.apk retention-days: 7 + - name: πŸ”„ Refresh PR build comment + uses: ./.github/actions/refresh-pr-comment + build-android-tv: if: (!contains(github.event.head_commit.message, '[skip ci]')) runs-on: ubuntu-26.04 name: πŸ€– Build Android APK (TV) permissions: contents: read + actions: write # dispatch artifact-comment.yml to refresh the PR comment steps: - name: πŸ—‘οΈ Free Disk Space @@ -212,12 +217,16 @@ jobs: android/app/build/outputs/apk/release/*.apk retention-days: 7 + - name: πŸ”„ Refresh PR build comment + uses: ./.github/actions/refresh-pr-comment + build-ios-phone: if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin')) runs-on: macos-26 name: 🍎 Build iOS IPA (Phone) permissions: contents: read + actions: write # dispatch artifact-comment.yml to refresh the PR comment steps: - name: πŸ“₯ Checkout code @@ -280,12 +289,16 @@ jobs: path: build-*.ipa retention-days: 7 + - name: πŸ”„ Refresh PR build comment + uses: ./.github/actions/refresh-pr-comment + build-ios-phone-unsigned: if: (!contains(github.event.head_commit.message, '[skip ci]')) runs-on: macos-26 name: 🍎 Build iOS IPA (Phone - Unsigned) permissions: contents: read + actions: write # dispatch artifact-comment.yml to refresh the PR comment steps: - name: πŸ“₯ Checkout code @@ -339,6 +352,9 @@ jobs: path: build/*.ipa retention-days: 7 + - name: πŸ”„ Refresh PR build comment + uses: ./.github/actions/refresh-pr-comment + build-ios-tv: # Disabled: EAS has no provisioning profiles / distribution cert for the tvOS # targets (app + StreamyfinTopShelf extension), so non-interactive signed @@ -349,6 +365,7 @@ jobs: name: 🍎 Build tvOS IPA permissions: contents: read + actions: write # dispatch artifact-comment.yml to refresh the PR comment steps: - name: πŸ“₯ Checkout code @@ -418,6 +435,7 @@ jobs: name: 🍎 Build tvOS IPA (Unsigned) permissions: contents: read + actions: write # dispatch artifact-comment.yml to refresh the PR comment steps: - name: πŸ“₯ Checkout code @@ -470,3 +488,6 @@ jobs: name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }} path: build/*.ipa retention-days: 7 + + - name: πŸ”„ Refresh PR build comment + uses: ./.github/actions/refresh-pr-comment