mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-16 19:00:28 +01:00
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.
579 lines
29 KiB
YAML
579 lines
29 KiB
YAML
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 += `<details>\n`;
|
|
commentBody += `<summary>📦 Build details & device compatibility</summary>\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 += `</details>\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 += `<sub>*Auto-generated by [GitHub Actions](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})*</sub>`;
|
|
commentBody += `\n<!-- streamyfin-artifact-comment -->`;
|
|
|
|
// 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('<!-- streamyfin-artifact-comment -->')
|
|
);
|
|
|
|
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;
|
|
}
|
|
}
|