name: πŸ“ Artifact Comment on PR concurrency: group: artifact-comment-${{ github.event.workflow_run.head_sha || github.sha }} cancel-in-progress: true on: workflow_dispatch: # Allow manual testing pull_request: # Show in PR checks and provide status updates types: [opened, synchronize, reopened] workflow_run: # Triggered when build workflows complete workflows: - "πŸ—οΈ Build Apps" types: - completed jobs: comment-artifacts: if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request') name: πŸ“¦ Post Build Artifacts runs-on: ubuntu-26.04 permissions: contents: read pull-requests: write actions: read steps: - name: πŸ” Get PR and Artifacts uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | // Check if we're running from a fork (more precise detection) const targetRepo = context.repo.owner + '/' + context.repo.repo; const prHeadRepo = context.payload.pull_request?.head?.repo?.full_name; const workflowHeadRepo = context.payload.workflow_run?.head_repository?.full_name; // For debugging console.log('πŸ” Repository detection:'); console.log('- Target repository:', targetRepo); console.log('- PR head repository:', prHeadRepo || 'N/A'); console.log('- Workflow head repository:', workflowHeadRepo || 'N/A'); console.log('- Event name:', context.eventName); // Only skip if it's actually a different repository (fork) const isFromFork = prHeadRepo && prHeadRepo !== targetRepo; const workflowFromFork = workflowHeadRepo && workflowHeadRepo !== targetRepo; if (isFromFork || workflowFromFork) { console.log('🚫 Workflow running from fork - skipping comment creation to avoid permission errors'); console.log('Fork repository:', prHeadRepo || workflowHeadRepo); console.log('Target repository:', targetRepo); return; } console.log('βœ… Same repository - proceeding with comment creation'); // Handle repository_dispatch, pull_request, and manual dispatch events let pr; let targetCommitSha; if (context.eventName === 'workflow_run') { // Find PR associated with this workflow run commit console.log('Workflow run event:', context.payload.workflow_run.name); const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ owner: context.repo.owner, repo: context.repo.repo, commit_sha: context.payload.workflow_run.head_sha }); if (pullRequests.length === 0) { console.log('No pull request found for commit:', context.payload.workflow_run.head_sha); return; } pr = pullRequests[0]; targetCommitSha = context.payload.workflow_run.head_sha; } else if (context.eventName === 'pull_request') { // Direct PR event pr = context.payload.pull_request; targetCommitSha = pr.head.sha; } else if (context.eventName === 'workflow_dispatch') { // For manual testing, try to find PR for current branch/commit console.log('Manual workflow dispatch triggered'); // First, try to find PRs associated with current commit try { const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ owner: context.repo.owner, repo: context.repo.repo, commit_sha: context.sha }); if (pullRequests.length > 0) { pr = pullRequests[0]; targetCommitSha = pr.head.sha; console.log(`Found PR #${pr.number} for commit ${context.sha.substring(0, 7)}`); } else { // Fallback: get latest open PR const { data: openPRs } = await github.rest.pulls.list({ owner: context.repo.owner, repo: context.repo.repo, state: 'open', sort: 'updated', direction: 'desc', per_page: 1 }); if (openPRs.length > 0) { pr = openPRs[0]; targetCommitSha = pr.head.sha; console.log(`Using latest open PR #${pr.number} for manual testing`); } else { console.log('No open PRs found for manual testing'); return; } } } catch (error) { console.log('Error finding PR for manual testing:', error.message); return; } } else { console.log('Unsupported event type:', context.eventName); return; } console.log(`Processing PR #${pr.number} for commit ${targetCommitSha.substring(0, 7)}`); // Get all recent workflow runs for this PR to collect artifacts from multiple builds const { data: workflowRuns } = await github.rest.actions.listWorkflowRunsForRepo({ owner: context.repo.owner, repo: context.repo.repo, head_sha: targetCommitSha, per_page: 30 }); // Filter for build workflows only, include active runs even if marked as cancelled const buildRuns = workflowRuns.workflow_runs .filter(run => (run.name.includes('Build Apps') || run.name.includes('Android APK Build') || run.name.includes('iOS IPA Build')) ) .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); console.log(`Found ${buildRuns.length} build workflow runs for this commit`); // Log current status of each build for debugging buildRuns.forEach(run => { console.log(`- ${run.name}: ${run.status} (${run.conclusion || 'no conclusion yet'}) - Created: ${run.created_at}`); }); // Collect artifacts and statuses from builds - prioritize active runs over completed ones let allArtifacts = []; let buildStatuses = {}; // Get the most relevant run for each workflow type (prioritize active over cancelled) const findBestRun = (nameFilter) => { const matchingRuns = buildRuns.filter(run => run.name.includes(nameFilter)); // First try to find an in-progress run const inProgressRun = matchingRuns.find(run => run.status === 'in_progress'); if (inProgressRun) return inProgressRun; // Then try to find a queued run const queuedRun = matchingRuns.find(run => run.status === 'queued'); if (queuedRun) return queuedRun; // Check if the workflow is completed but has non-cancelled jobs const completedRuns = matchingRuns.filter(run => run.status === 'completed'); for (const run of completedRuns) { // We'll check individual jobs later to see if they're actually running if (run.conclusion !== 'cancelled') { return run; } } // Finally fall back to most recent run (even if cancelled at workflow level) return matchingRuns[0]; // Already sorted by most recent first }; const latestAppsRun = findBestRun('Build Apps'); 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'})`); try { // Get all jobs for this workflow run const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({ owner: context.repo.owner, repo: context.repo.repo, run_id: latestAppsRun.id }); console.log(`Found ${jobs.jobs.length} jobs in workflow run`); jobs.jobs.forEach(job => { console.log(`- Job: ${job.name} | Status: ${job.status} | Conclusion: ${job.conclusion || 'none'}`); }); // Check if we have any actually running jobs (not cancelled) const activeJobs = jobs.jobs.filter(job => job.status === 'in_progress' || job.status === 'queued' || (job.status === 'completed' && job.conclusion !== 'cancelled') ); console.log(`Found ${activeJobs.length} active (non-cancelled) jobs out of ${jobs.jobs.length} total jobs`); // If no jobs are actually running, skip this workflow if (activeJobs.length === 0 && latestAppsRun.conclusion === 'cancelled') { console.log('All jobs are cancelled, skipping this workflow run'); return; // Exit early } // Create individual status for each job for (const [platform, jobNames] of Object.entries(jobMappings)) { const job = findJobForTarget(jobs.jobs, jobNames); if (job) { buildStatuses[platform] = { name: job.name, status: job.status, conclusion: job.conclusion, url: job.html_url, runId: latestAppsRun.id, created_at: job.started_at || latestAppsRun.created_at, started_at: job.started_at, completed_at: job.completed_at }; console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`); } else { console.log(`No job found for ${platform}, using workflow status as fallback`); buildStatuses[platform] = { name: latestAppsRun.name, status: latestAppsRun.status, conclusion: latestAppsRun.conclusion, url: latestAppsRun.html_url, runId: latestAppsRun.id, created_at: latestAppsRun.created_at, started_at: latestAppsRun.run_started_at, completed_at: latestAppsRun.updated_at }; } } } catch (error) { console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message); // Fallback to workflow-level status for every build target. // Keys must match jobMappings / buildTargets statusKey values. const fallbackStatus = { name: latestAppsRun.name, status: latestAppsRun.status, conclusion: latestAppsRun.conclusion, url: latestAppsRun.html_url, runId: latestAppsRun.id, created_at: latestAppsRun.created_at, started_at: latestAppsRun.run_started_at, completed_at: latestAppsRun.updated_at }; for (const platform of Object.keys(jobMappings)) { buildStatuses[platform] = fallbackStatus; } } // Collect artifacts if any job has completed successfully if (latestAppsRun.status === 'completed' || Object.values(buildStatuses).some(status => status.conclusion === 'success')) { try { const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ owner: context.repo.owner, repo: context.repo.repo, run_id: latestAppsRun.id }); allArtifacts.push(...artifacts.artifacts); } catch (error) { console.log(`Failed to get apps artifacts for run ${latestAppsRun.id}:`, error.message); } } } else { // Fallback to separate workflows (for backward compatibility) if (latestAndroidRun) { buildStatuses['Android'] = { name: latestAndroidRun.name, status: latestAndroidRun.status, conclusion: latestAndroidRun.conclusion, url: latestAndroidRun.html_url, runId: latestAndroidRun.id, created_at: latestAndroidRun.created_at }; if (latestAndroidRun.conclusion === 'success') { try { const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ owner: context.repo.owner, repo: context.repo.repo, run_id: latestAndroidRun.id }); allArtifacts.push(...artifacts.artifacts); } catch (error) { console.log(`Failed to get Android artifacts for run ${latestAndroidRun.id}:`, error.message); } } } if (latestIOSRun) { buildStatuses['iOS'] = { name: latestIOSRun.name, status: latestIOSRun.status, conclusion: latestIOSRun.conclusion, url: latestIOSRun.html_url, runId: latestIOSRun.id, created_at: latestIOSRun.created_at }; if (latestIOSRun.conclusion === 'success') { try { const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ owner: context.repo.owner, repo: context.repo.repo, run_id: latestIOSRun.id }); allArtifacts.push(...artifacts.artifacts); } catch (error) { console.log(`Failed to get iOS artifacts for run ${latestIOSRun.id}:`, error.message); } } } } console.log(`Collected ${allArtifacts.length} total artifacts from all builds`); // Debug: Show which workflow we're using and its status if (latestAppsRun) { console.log(`Using consolidated workflow: ${latestAppsRun.name} (${latestAppsRun.status}/${latestAppsRun.conclusion})`); } else { console.log(`Using separate workflows - Android: ${latestAndroidRun?.name || 'none'}, iOS: ${latestIOSRun?.name || 'none'}`); } // Debug: List all artifacts found allArtifacts.forEach(artifact => { 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 commentBody += `### πŸ“¦ Build Artifacts\n\n`; commentBody += `| Platform | Device | Status | Download |\n`; commentBody += `|----------|--------|--------|---------|\n`; // Process each expected build target individually 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: /^(?!.*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: /^(?!.*unsigned).*ios.*tv.*ipa/i }, { name: 'tvOS Unsigned', platform: '🍎', device: 'πŸ“Ί TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i } ]; for (const target of buildTargets) { // Find matching job status directly const matchingStatus = buildStatuses[target.statusKey]; // Find matching artifact const matchingArtifact = allArtifacts.find(artifact => target.artifactPattern.test(artifact.name) ); let status = '⏳ Pending'; let downloadLink = '*Waiting for build...*'; // Signed tvOS stays disabled until EAS has tvOS provisioning // profiles (app + TopShelf targets); non-interactive builds can't // create them. Unsigned tvOS builds, so it flows through normally. if (target.name === 'tvOS') { status = 'πŸ’€ Disabled'; downloadLink = '*Disabled β€” signed tvOS needs EAS provisioning profiles*'; } else if (matchingStatus) { if (matchingStatus.conclusion === 'success' && matchingArtifact) { status = 'βœ… Complete'; const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`; const fileType = target.name.includes('Android') ? 'APK' : 'IPA'; // Format file size const sizeInMB = (matchingArtifact.size_in_bytes / (1024 * 1024)).toFixed(1); const sizeInfo = `(${sizeInMB} MB)`; // Calculate build duration let durationInfo = ''; if (matchingStatus.started_at && matchingStatus.completed_at) { const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at); durationInfo = ` - ${fmtDuration(durationMs)}`; } downloadLink = `[πŸ“₯ Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`; } else if (matchingStatus.conclusion === 'failure') { status = `❌ [Failed](${matchingStatus.url})`; downloadLink = '*Build failed*'; } else if (matchingStatus.conclusion === 'cancelled') { status = `βšͺ [Cancelled](${matchingStatus.url})`; downloadLink = '*Build cancelled*'; } else if (matchingStatus.status === 'in_progress') { status = `πŸ”„ [Building...](${matchingStatus.url})`; 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})`; 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})`; downloadLink = '*Finalizing build...*'; } else if (matchingStatus.status === 'completed' && matchingStatus.conclusion === 'success' && !matchingArtifact) { // Build succeeded but artifacts not yet available status = `⏳ [Processing artifacts...](${matchingStatus.url})`; downloadLink = '*Preparing download...*'; } else { // Fallback for any unexpected states status = `❓ [${matchingStatus.status}/${matchingStatus.conclusion || 'pending'}](${matchingStatus.url})`; downloadLink = `*Status: ${matchingStatus.status}, Conclusion: ${matchingStatus.conclusion || 'pending'}*`; } } commentBody += `| ${target.platform} ${target.name} | ${target.device} | ${status} | ${downloadLink} |\n`; } commentBody += `\n`; // 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`; } else { commentBody += `⏳ **Builds are starting up...** This comment will update automatically as each build completes.\n\n`; } commentBody += `*Auto-generated by [GitHub Actions](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})*`; commentBody += `\n`; // Try to find existing bot comment to update (with permission check) try { const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number }); const botComment = comments.find(comment => comment.user.type === 'Bot' && comment.body.includes('') ); if (botComment) { // Update existing comment await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: botComment.id, body: commentBody }); console.log(`βœ… Updated comment ${botComment.id} on PR #${pr.number}`); } else { // Create new comment await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number, body: commentBody }); console.log(`βœ… Created new comment on PR #${pr.number}`); } } catch (error) { if (error.status === 403) { console.log('🚫 Permission denied - likely running from a fork. Skipping comment creation.'); console.log('Error details:', error.message); // Log the build status instead of commenting console.log('πŸ“Š Build Status Summary:'); for (const target of buildTargets) { const matchingStatus = buildStatuses[target.statusKey]; if (matchingStatus) { console.log(`- ${target.name}: ${matchingStatus.status}/${matchingStatus.conclusion || 'none'}`); } } } else { // Re-throw other errors throw error; } }