From 2a195d9ba520b79d4e97c4b4826ed461b2aa5f3f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 25 Sep 2025 00:15:46 +0200 Subject: [PATCH 01/27] chore(deps): Update actions/cache action to v4.3.0 (#1084) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build-android.yml | 6 +++--- .github/workflows/build-ios.yml | 2 +- .github/workflows/check-lockfile.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 2b79e2dd..bc9b9ea1 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -39,7 +39,7 @@ jobs: bun-version: latest - name: ๐Ÿ’พ Cache Bun dependencies - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} @@ -53,7 +53,7 @@ jobs: bun run submodule-reload - name: ๐Ÿ’พ Cache Gradle global - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ~/.gradle/caches @@ -70,7 +70,7 @@ jobs: fi - name: ๐Ÿ’พ Cache project Gradle (.gradle) - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: android/.gradle key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index b7486da5..98b587b3 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -44,7 +44,7 @@ jobs: bun-version: latest - name: ๐Ÿ’พ Cache Bun dependencies - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} diff --git a/.github/workflows/check-lockfile.yml b/.github/workflows/check-lockfile.yml index 6134ad5f..cc606b19 100644 --- a/.github/workflows/check-lockfile.yml +++ b/.github/workflows/check-lockfile.yml @@ -32,7 +32,7 @@ jobs: bun-version: latest - name: ๐Ÿ’พ Cache Bun dependencies - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ~/.bun/install/cache From ead37aa8065c288dd88ccbfdbd74d4dc42e02c38 Mon Sep 17 00:00:00 2001 From: Gauvain <68083474+Gauvino@users.noreply.github.com> Date: Thu, 25 Sep 2025 22:47:49 +0200 Subject: [PATCH 02/27] fix: resolve type issues and improve component reliability (#1078) --- .../jellyseerr/page.tsx | 26 ++++++--- components/common/JellyseerrItemRouter.tsx | 2 +- components/common/TouchableItemRouter.tsx | 55 +++++++++++++++++-- components/home/LargeMovieCarousel.tsx | 9 ++- components/inputs/PinInput.tsx | 14 ++++- components/jellyseerr/PersonPoster.tsx | 2 +- .../jellyseerr/discover/CompanySlide.tsx | 5 +- components/jellyseerr/discover/GenreSlide.tsx | 5 +- .../discover/RecentRequestsSlide.tsx | 19 ++++++- components/series/CastAndCrew.tsx | 18 +++--- components/series/SeasonEpisodesCarousel.tsx | 3 +- components/series/SeasonPicker.tsx | 5 +- components/settings/HomeIndex.tsx | 8 +-- components/video-player/controls/Controls.tsx | 3 +- .../controls/contexts/VideoContext.tsx | 3 +- .../controls/dropdown/DropdownView.tsx | 3 +- hooks/useJellyseerr.ts | 4 +- translations/en.json | 23 +++++++- utils/atoms/settings.ts | 1 + 19 files changed, 147 insertions(+), 61 deletions(-) diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx index 7113c24f..3b076235 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx @@ -139,7 +139,15 @@ const Page: React.FC = () => { } requestMedia(mediaTitle, body, refetch); - }, [details, result, requestMedia, hasAdvancedRequestPermission]); + }, [ + details, + result, + requestMedia, + hasAdvancedRequestPermission, + mediaTitle, + refetch, + mediaType, + ]); const isAnime = useMemo( () => @@ -277,12 +285,16 @@ const Page: React.FC = () => { ) diff --git a/app/_layout.tsx b/app/_layout.tsx index c94f540e..677fc6ce 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -395,12 +395,17 @@ function Layout() { appState.current.match(/inactive|background/) && nextAppState === "active" ) { - BackGroundDownloader.checkForExistingDownloads(); + BackGroundDownloader.checkForExistingDownloads().catch( + (error: unknown) => { + writeErrorLog("Failed to resume background downloads", error); + }, + ); } }); - BackGroundDownloader.checkForExistingDownloads(); - + BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => { + writeErrorLog("Failed to resume background downloads", error); + }); return () => { subscription.remove(); }; diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 60a248c7..80ead77a 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -225,7 +225,7 @@ export const DownloadItems: React.FC = ({ if (!mediaSource) { console.error(`Could not get download URL for ${item.Name}`); toast.error( - t("Could not get download URL for {{itemName}}", { + t("home.downloads.toasts.could_not_get_download_url_for_item", { itemName: item.Name, }), ); diff --git a/components/downloads/DownloadCard.tsx b/components/downloads/DownloadCard.tsx index 5ffce752..b90c4836 100644 --- a/components/downloads/DownloadCard.tsx +++ b/components/downloads/DownloadCard.tsx @@ -6,6 +6,7 @@ import { t } from "i18next"; import { useMemo } from "react"; import { ActivityIndicator, + Platform, TouchableOpacity, type TouchableOpacityProps, View, @@ -109,9 +110,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { /> )} - {/* Action buttons in top right corner */} - - {process.status === "downloading" && ( + {/* Action buttons in bottom right corner */} + + {process.status === "downloading" && Platform.OS !== "ios" && ( handlePause(process.id)} className='p-1' @@ -119,7 +120,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { )} - {process.status === "paused" && ( + {process.status === "paused" && Platform.OS !== "ios" && ( handleResume(process.id)} className='p-1' diff --git a/components/series/SeriesActions.tsx b/components/series/SeriesActions.tsx index 64a9dbfa..a2cd32ba 100644 --- a/components/series/SeriesActions.tsx +++ b/components/series/SeriesActions.tsx @@ -1,6 +1,7 @@ import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Alert, Linking, @@ -16,6 +17,7 @@ interface Props extends ViewProps { } export const ItemActions = ({ item, ...props }: Props) => { + const { t } = useTranslation(); const trailerLink = useMemo(() => { if ("RemoteTrailers" in item && item.RemoteTrailers?.[0]?.Url) { return item.RemoteTrailers[0].Url; @@ -30,7 +32,7 @@ export const ItemActions = ({ item, ...props }: Props) => { const openTrailer = useCallback(async () => { if (!trailerLink) { - Alert.alert("No trailer available"); + Alert.alert(t("common.no_trailer_available")); return; } @@ -39,7 +41,7 @@ export const ItemActions = ({ item, ...props }: Props) => { } catch (err) { console.error("Failed to open trailer link:", err); } - }, [trailerLink]); + }, [trailerLink, t]); return ( diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx index e0ca1147..d4d7e598 100644 --- a/components/settings/OtherSettings.tsx +++ b/components/settings/OtherSettings.tsx @@ -41,10 +41,10 @@ export const OtherSettings: React.FC = () => { if (settings?.autoDownload === true && !registered) { registerBackgroundFetchAsync(); - toast.success("Background downloads enabled"); + toast.success(t("home.settings.toasts.background_downloads_enabled")); } else if (settings?.autoDownload === false && registered) { unregisterBackgroundFetchAsync(); - toast.info("Background downloads disabled"); + toast.info(t("home.settings.toasts.background_downloads_disabled")); } else if (settings?.autoDownload === true && registered) { // Don't to anything } else if (settings?.autoDownload === false && !registered) { diff --git a/plugins/withRNBackgroundDownloader.js b/plugins/withRNBackgroundDownloader.js index 83e1fb69..af24d642 100644 --- a/plugins/withRNBackgroundDownloader.js +++ b/plugins/withRNBackgroundDownloader.js @@ -50,9 +50,11 @@ function withRNBackgroundDownloader(config) { // Expo 53's xcodeโ€‘js doesn't expose pbxTargets(). // Setting the property once at the project level is sufficient. ["Debug", "Release"].forEach((cfg) => { + // Use the detected projectName to set the bridging header path instead of a hardcoded value + const bridgingHeaderPath = `${projectName}/${projectName}-Bridging-Header.h`; project.updateBuildProperty( "SWIFT_OBJC_BRIDGING_HEADER", - "Streamyfin/Streamyfin-Bridging-Header.h", + bridgingHeaderPath, cfg, ); }); diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index c3180c0a..4306b8d6 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -25,7 +25,7 @@ import { useSettings } from "@/utils/atoms/settings"; import { getOrSetDeviceId } from "@/utils/device"; import useDownloadHelper from "@/utils/download"; import { getItemImage } from "@/utils/getItemImage"; -import { writeToLog } from "@/utils/log"; +import { dumpDownloadDiagnostics, writeToLog } from "@/utils/log"; import { storage } from "@/utils/mmkv"; import { fetchAndParseSegments } from "@/utils/segments"; import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay"; @@ -42,37 +42,60 @@ const BackGroundDownloader = !Platform.isTV ? require("@kesha-antonov/react-native-background-downloader") : null; +// Cap progress at 99% to avoid showing 100% before the download is actually complete +const MAX_PROGRESS_BEFORE_COMPLETION = 99; + +// Estimate the total download size in bytes for a job. If the media source +// provides a Size, use that. Otherwise, if we have a bitrate and run time +// (RunTimeTicks), approximate size = (bitrate bits/sec * seconds) / 8. const calculateEstimatedSize = (p: JobStatus): number => { - let size = p.mediaSource.Size; - const maxBitrate = p.maxBitrate.value; - if ( - maxBitrate && - size && - p.mediaSource.Bitrate && - maxBitrate < p.mediaSource.Bitrate - ) { - size = (size / p.mediaSource.Bitrate) * maxBitrate; - } - // This function is for estimated size, so just return the adjusted size - return size ?? 0; -}; + const size = p.mediaSource?.Size || 0; + const maxBitrate = p.maxBitrate?.value; + const runTimeTicks = (p.item?.RunTimeTicks || 0) as number; -// Helper to calculate download speed -const calculateSpeed = ( - process: JobStatus, - newBytesDownloaded: number, -): number | undefined => { - const { bytesDownloaded: oldBytes = 0, lastProgressUpdateTime } = process; - const deltaBytes = newBytesDownloaded - oldBytes; - - if (lastProgressUpdateTime && deltaBytes > 0) { - const deltaTimeInSeconds = - (Date.now() - new Date(lastProgressUpdateTime).getTime()) / 1000; - if (deltaTimeInSeconds > 0) { - return deltaBytes / deltaTimeInSeconds; + if (!size && maxBitrate && runTimeTicks > 0) { + // Jellyfin RunTimeTicks are in 10,000,000 ticks per second + const seconds = runTimeTicks / 10000000; + if (seconds > 0) { + // maxBitrate is in bits per second; convert to bytes + return Math.round((maxBitrate / 8) * seconds); } } - return undefined; + + return size || 0; +}; + +// Calculate download speed in bytes/sec based on a job's last update time +// and previously recorded bytesDownloaded. +const calculateSpeed = ( + p: JobStatus, + currentBytesDownloaded?: number, +): number | undefined => { + // Prefer session-only deltas when available: lastSessionBytes + lastSessionUpdateTime + const now = Date.now(); + + if (p.lastSessionUpdateTime && p.lastSessionBytes !== undefined) { + const last = new Date(p.lastSessionUpdateTime).getTime(); + const deltaTime = (now - last) / 1000; + if (deltaTime > 0) { + const current = + currentBytesDownloaded ?? p.bytesDownloaded ?? p.lastSessionBytes; + const deltaBytes = current - p.lastSessionBytes; + if (deltaBytes > 0) return deltaBytes / deltaTime; + } + } + + // Fallback to total-based deltas for compatibility + if (!p.lastProgressUpdateTime || p.bytesDownloaded === undefined) + return undefined; + const last = new Date(p.lastProgressUpdateTime).getTime(); + const deltaTime = (now - last) / 1000; + if (deltaTime <= 0) return undefined; + const prev = p.bytesDownloaded || 0; + const current = currentBytesDownloaded ?? prev; + const deltaBytes = current - prev; + if (deltaBytes <= 0) return undefined; + return deltaBytes / deltaTime; }; export const processesAtom = atom([]); @@ -170,27 +193,96 @@ function useDownloadProvider() { const currentProcesses = [...processes, ...missingProcesses]; const updatedProcesses = currentProcesses.map((p) => { - // fallback. Doesn't really work for transcodes as they may be a lot smaller. - // We make an wild guess by comparing bitrates + // Enhanced filtering to prevent iOS zombie task interference + // Only update progress for downloads that are actively downloading + if (p.status !== "downloading") { + return p; + } + + // Find task for this process const task = tasks.find((s: any) => s.id === p.id); + if (!task) { + return p; // No task found, keep current state + } + + /* + // TODO: Uncomment this block to re-enable iOS zombie task detection + // iOS: Extra validation to prevent zombie task interference + if (Platform.OS === "ios") { + // Check if we have multiple tasks for same ID (zombie detection) + const tasksForId = tasks.filter((t: any) => t.id === p.id); + if (tasksForId.length > 1) { + console.warn( + `[UPDATE] Detected ${tasksForId.length} zombie tasks for ${p.id}, ignoring progress update`, + ); + return p; // Don't update progress from potentially conflicting tasks + } + + // If task state looks suspicious (e.g., iOS task stuck in background), be conservative + if ( + task.state && + ["SUSPENDED", "PAUSED"].includes(task.state) && + p.status === "downloading" + ) { + console.warn( + `[UPDATE] Task ${p.id} has suspicious state ${task.state}, ignoring progress update`, + ); + return p; + } + } + */ + if (task && p.status === "downloading") { const estimatedSize = calculateEstimatedSize(p); let progress = p.progress; - if (estimatedSize > 0) { - progress = (100 / estimatedSize) * task.bytesDownloaded; + + // If we have a pausedProgress snapshot then merge current session + // progress into it. We accept pausedProgress === 0 as valid because + // users can pause immediately after starting. + if (p.pausedProgress !== undefined) { + const totalBytesDownloaded = + (p.pausedBytes ?? 0) + task.bytesDownloaded; + + // Calculate progress based on total bytes downloaded vs estimated size + progress = + estimatedSize > 0 + ? (totalBytesDownloaded / estimatedSize) * 100 + : 0; + + // Use the total accounted bytes when computing speed so the + // displayed speed and progress remain consistent after resume. + const speed = calculateSpeed(p, totalBytesDownloaded); + + return { + ...p, + progress: Math.min(progress, MAX_PROGRESS_BEFORE_COMPLETION), + speed, + bytesDownloaded: totalBytesDownloaded, + lastProgressUpdateTime: new Date(), + estimatedTotalSizeBytes: estimatedSize, + // Set session bytes to total bytes downloaded + lastSessionBytes: totalBytesDownloaded, + lastSessionUpdateTime: new Date(), + }; + } else { + if (estimatedSize > 0) { + progress = (100 / estimatedSize) * task.bytesDownloaded; + } + if (progress >= 100) { + progress = MAX_PROGRESS_BEFORE_COMPLETION; + } + const speed = calculateSpeed(p, task.bytesDownloaded); + return { + ...p, + progress, + speed, + bytesDownloaded: task.bytesDownloaded, + lastProgressUpdateTime: new Date(), + estimatedTotalSizeBytes: estimatedSize, + lastSessionBytes: task.bytesDownloaded, + lastSessionUpdateTime: new Date(), + }; } - if (progress >= 100) { - progress = 99; - } - const speed = calculateSpeed(p, task.bytesDownloaded); - return { - ...p, - progress, - speed, - bytesDownloaded: task.bytesDownloaded, - lastProgressUpdateTime: new Date(), - estimatedTotalSizeBytes: estimatedSize, - }; } return p; }); @@ -372,10 +464,76 @@ function useDownloadProvider() { async (process: JobStatus) => { if (!process?.item.Id || !authHeader) throw new Error("No item id"); + // Enhanced cleanup for existing tasks to prevent duplicates + try { + const allTasks = await BackGroundDownloader.checkForExistingDownloads(); + const existingTasks = allTasks?.filter((t: any) => t.id === process.id); + + if (existingTasks && existingTasks.length > 0) { + console.log( + `[START] Found ${existingTasks.length} existing task(s) for ${process.id}, cleaning up...`, + ); + + for (let i = 0; i < existingTasks.length; i++) { + const existingTask = existingTasks[i]; + console.log( + `[START] Cleaning up task ${i + 1}/${existingTasks.length} for ${process.id}`, + ); + + try { + /* + // TODO: Uncomment this block to re-enable iOS-specific cleanup + // iOS: More aggressive cleanup sequence + if (Platform.OS === "ios") { + try { + await existingTask.pause(); + await new Promise((resolve) => setTimeout(resolve, 50)); + } catch (_pauseErr) { + // Ignore pause errors + } + + await existingTask.stop(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Multiple complete handler calls to ensure cleanup + BackGroundDownloader.completeHandler(process.id); + await new Promise((resolve) => setTimeout(resolve, 25)); + } else { + */ + + // Simple cleanup for all platforms (currently Android only) + await existingTask.stop(); + BackGroundDownloader.completeHandler(process.id); + + /* } // End of iOS block - uncomment when re-enabling iOS functionality */ + + console.log( + `[START] Successfully cleaned up task ${i + 1} for ${process.id}`, + ); + } catch (taskError) { + console.warn( + `[START] Failed to cleanup task ${i + 1} for ${process.id}:`, + taskError, + ); + } + } + + // Cleanup delay (simplified for Android) + const cleanupDelay = 200; // Platform.OS === "ios" ? 500 : 200; + await new Promise((resolve) => setTimeout(resolve, cleanupDelay)); + console.log(`[START] Cleanup completed for ${process.id}`); + } + } catch (error) { + console.warn( + `[START] Failed to check/cleanup existing tasks for ${process.id}:`, + error, + ); + } + updateProcess(process.id, { speed: undefined, status: "downloading", - progress: 0, + progress: process.progress || 0, // Preserve existing progress for resume }); BackGroundDownloader?.setConfig({ @@ -396,21 +554,42 @@ function useDownloadProvider() { .begin(() => { updateProcess(process.id, { status: "downloading", - progress: 0, - bytesDownloaded: 0, + progress: process.progress || 0, + bytesDownloaded: process.bytesDownloaded || 0, lastProgressUpdateTime: new Date(), + lastSessionBytes: process.lastSessionBytes || 0, + lastSessionUpdateTime: new Date(), }); }) .progress( throttle((data) => { updateProcess(process.id, (currentProcess) => { - const percent = (data.bytesDownloaded / data.bytesTotal) * 100; + // If this is a resumed download, add the paused bytes to current session bytes + const resumedBytes = currentProcess.pausedBytes || 0; + const totalBytes = data.bytesDownloaded + resumedBytes; + + // Calculate progress based on total bytes if we have resumed bytes + let percent: number; + if (resumedBytes > 0 && data.bytesTotal > 0) { + // For resumed downloads, calculate based on estimated total size + const estimatedTotal = + currentProcess.estimatedTotalSizeBytes || + data.bytesTotal + resumedBytes; + percent = (totalBytes / estimatedTotal) * 100; + } else { + // For fresh downloads, use normal calculation + percent = (data.bytesDownloaded / data.bytesTotal) * 100; + } + return { - speed: calculateSpeed(currentProcess, data.bytesDownloaded), + speed: calculateSpeed(currentProcess, totalBytes), status: "downloading", - progress: percent, - bytesDownloaded: data.bytesDownloaded, + progress: Math.min(percent, MAX_PROGRESS_BEFORE_COMPLETION), + bytesDownloaded: totalBytes, lastProgressUpdateTime: new Date(), + // update session-only counters - use current session bytes only for speed calc + lastSessionBytes: data.bytesDownloaded, + lastSessionUpdateTime: new Date(), }; }); }, 500), @@ -542,7 +721,17 @@ function useDownloadProvider() { if (activeDownloads < concurrentLimit) { const queuedDownload = processes.find((p) => p.status === "queued"); if (queuedDownload) { - startDownload(queuedDownload); + // Reserve the slot immediately to avoid race where startDownload's + // asynchronous begin callback hasn't executed yet and multiple + // downloads are started, bypassing the concurrent limit. + updateProcess(queuedDownload.id, { status: "downloading" }); + startDownload(queuedDownload).catch((error) => { + console.error("Failed to start download:", error); + updateProcess(queuedDownload.id, { status: "error" }); + toast.error(t("home.downloads.toasts.failed_to_start_download"), { + description: error.message || "Unknown error", + }); + }); } } }, [processes, settings?.remuxConcurrentLimit, startDownload]); @@ -551,8 +740,38 @@ function useDownloadProvider() { async (id: string) => { const tasks = await BackGroundDownloader.checkForExistingDownloads(); const task = tasks?.find((t: any) => t.id === id); - task?.stop(); - BackGroundDownloader.completeHandler(id); + if (task) { + // On iOS, suspended tasks need to be cancelled properly + if (Platform.OS === "ios") { + const state = task.state || task.state?.(); + if ( + state === "PAUSED" || + state === "paused" || + state === "SUSPENDED" || + state === "suspended" + ) { + // For suspended tasks, we need to resume first, then stop + try { + await task.resume(); + // Small delay to allow resume to take effect + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch (_resumeError) { + // Resume might fail, continue with stop + } + } + } + + try { + task.stop(); + } catch (_err) { + // ignore stop errors + } + try { + BackGroundDownloader.completeHandler(id); + } catch (_err) { + // ignore + } + } setProcesses((prev) => prev.filter((process) => process.id !== id)); manageDownloadQueue(); }, @@ -575,7 +794,7 @@ function useDownloadProvider() { intermediates: true, }); } catch (_error) { - toast.error(t("Failed to clean cache directory.")); + toast.error(t("home.downloads.toasts.failed_to_clean_cache_directory")); } }; @@ -611,9 +830,13 @@ function useDownloadProvider() { status: "queued", timestamp: new Date(), }; - setProcesses((prev) => [...prev, job]); + setProcesses((prev) => { + // Remove any existing processes for this item to prevent duplicates + const filtered = prev.filter((p) => p.id !== item.Id); + return [...filtered, job]; + }); toast.success( - t("home.downloads.toasts.download_stated_for_item", { + t("home.downloads.toasts.download_started_for_item", { item: item.Name, }), { @@ -791,12 +1014,99 @@ function useDownloadProvider() { const process = processes.find((p) => p.id === id); if (!process) throw new Error("No active download"); + // TODO: iOS pause functionality temporarily disabled due to background task issues + // Remove this check to re-enable iOS pause functionality in the future + if (Platform.OS === "ios") { + console.warn( + `[PAUSE] Pause functionality temporarily disabled on iOS for ${id}`, + ); + throw new Error("Pause functionality is currently disabled on iOS"); + } + const tasks = await BackGroundDownloader.checkForExistingDownloads(); const task = tasks?.find((t: any) => t.id === id); if (!task) throw new Error("No task found"); - task.pause(); - updateProcess(id, { status: "paused" }); + // Get current progress before stopping + const currentProgress = process.progress; + const currentBytes = process.bytesDownloaded || task.bytesDownloaded || 0; + + console.log( + `[PAUSE] Starting pause for ${id}. Current bytes: ${currentBytes}, Progress: ${currentProgress}%`, + ); + + try { + /* + // TODO: Uncomment this block to re-enable iOS pause functionality + // iOS-specific aggressive cleanup approach based on GitHub issue #26 + if (Platform.OS === "ios") { + // Get ALL tasks for this ID - there might be multiple zombie tasks + const allTasks = + await BackGroundDownloader.checkForExistingDownloads(); + const tasksForId = allTasks?.filter((t: any) => t.id === id) || []; + + console.log(`[PAUSE] Found ${tasksForId.length} task(s) for ${id}`); + + // Stop ALL tasks for this ID to prevent zombie processes + for (let i = 0; i < tasksForId.length; i++) { + const taskToStop = tasksForId[i]; + console.log( + `[PAUSE] Stopping task ${i + 1}/${tasksForId.length} for ${id}`, + ); + + try { + // iOS: pause โ†’ stop sequence with delays (based on issue research) + await taskToStop.pause(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + await taskToStop.stop(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + console.log( + `[PAUSE] Successfully stopped task ${i + 1} for ${id}`, + ); + } catch (taskError) { + console.warn( + `[PAUSE] Failed to stop task ${i + 1} for ${id}:`, + taskError, + ); + } + } + + // Extra cleanup delay for iOS NSURLSession to fully stop + await new Promise((resolve) => setTimeout(resolve, 500)); + } else { + */ + + // Android: simpler approach (currently the only active platform) + await task.stop(); + + /* } // End of iOS block - uncomment when re-enabling iOS functionality */ + + // Clean up the native task handler + try { + BackGroundDownloader.completeHandler(id); + } catch (_err) { + console.warn(`[PAUSE] Handler cleanup warning for ${id}:`, _err); + } + + // Update process state to paused + updateProcess(id, { + status: "paused", + progress: currentProgress, + bytesDownloaded: currentBytes, + pausedAt: new Date(), + pausedProgress: currentProgress, + pausedBytes: currentBytes, + lastSessionBytes: process.lastSessionBytes ?? currentBytes, + lastSessionUpdateTime: process.lastSessionUpdateTime ?? new Date(), + }); + + console.log(`Download paused successfully: ${id}`); + } catch (error) { + console.error("Error pausing task:", error); + throw error; + } }, [processes, updateProcess], ); @@ -806,38 +1116,79 @@ function useDownloadProvider() { const process = processes.find((p) => p.id === id); if (!process) throw new Error("No active download"); - const tasks = await BackGroundDownloader.checkForExistingDownloads(); - const task = tasks?.find((t: any) => t.id === id); - if (!task) throw new Error("No task found"); - - // Check if task state allows resuming - if (task.state === "FAILED") { + // TODO: iOS resume functionality temporarily disabled due to background task issues + // Remove this check to re-enable iOS resume functionality in the future + if (Platform.OS === "ios") { console.warn( - "Download task failed, cannot resume. Restarting download.", + `[RESUME] Resume functionality temporarily disabled on iOS for ${id}`, ); - // For failed tasks, we need to restart rather than resume - await startDownload(process); - return; + throw new Error("Resume functionality is currently disabled on iOS"); } - try { - task.resume(); - updateProcess(id, { status: "downloading" }); - } catch (error: any) { - // Handle specific ERROR_CANNOT_RESUME error - if ( - error?.error === "ERROR_CANNOT_RESUME" || - error?.errorCode === 1008 - ) { - console.warn("Cannot resume download, attempting to restart instead"); - await startDownload(process); - return; // Return early to prevent error from bubbling up - } else { - // Only log error for non-handled cases - console.error("Error resuming download:", error); - throw error; // Re-throw other errors + console.log( + `[RESUME] Attempting to resume ${id}. Paused bytes: ${process.pausedBytes}, Progress: ${process.pausedProgress}%`, + ); + + /* + // TODO: Uncomment this block to re-enable iOS resume functionality + // Enhanced cleanup for iOS based on GitHub issue research + if (Platform.OS === "ios") { + try { + // Clean up any lingering zombie tasks first (critical for iOS) + const allTasks = + await BackGroundDownloader.checkForExistingDownloads(); + const existingTasks = allTasks?.filter((t: any) => t.id === id) || []; + + if (existingTasks.length > 0) { + console.log( + `[RESUME] Found ${existingTasks.length} lingering task(s), cleaning up...`, + ); + + for (const task of existingTasks) { + try { + await task.stop(); + BackGroundDownloader.completeHandler(id); + } catch (cleanupError) { + console.warn(`[RESUME] Cleanup error:`, cleanupError); + } + } + + // Wait for iOS cleanup to complete + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } catch (error) { + console.warn(`[RESUME] Pre-resume cleanup failed:`, error); } } + */ + + // Simple approach: always restart the download from where we left off + // This works consistently across all platforms (currently Android only) + if ( + process.pausedProgress !== undefined && + process.pausedBytes !== undefined + ) { + // We have saved pause state - restore it and restart + updateProcess(id, { + progress: process.pausedProgress, + bytesDownloaded: process.pausedBytes, + status: "downloading", + // Reset session counters for proper speed calculation + lastSessionBytes: process.pausedBytes, + lastSessionUpdateTime: new Date(), + }); + + // Small delay to ensure any cleanup in startDownload completes + await new Promise((resolve) => setTimeout(resolve, 100)); + + const updatedProcess = processes.find((p) => p.id === id); + await startDownload(updatedProcess || process); + + console.log(`Download resumed successfully: ${id}`); + } else { + // No pause state - start from beginning + await startDownload(process); + } }, [processes, updateProcess, startDownload], ); @@ -861,6 +1212,21 @@ function useDownloadProvider() { cleanCacheDirectory, updateDownloadedItem, appSizeUsage, + dumpDownloadDiagnostics: async (id?: string) => { + // Collect JS-side processes and native task info (best-effort) + const tasks = BackGroundDownloader + ? await BackGroundDownloader.checkForExistingDownloads() + : []; + const extra: any = { + processes, + nativeTasks: tasks || [], + }; + if (id) { + const p = processes.find((x) => x.id === id); + extra.focusedProcess = p || null; + } + return dumpDownloadDiagnostics(extra); + }, }; } diff --git a/providers/Downloads/types.ts b/providers/Downloads/types.ts index ee74b25d..cff87ddf 100644 --- a/providers/Downloads/types.ts +++ b/providers/Downloads/types.ts @@ -129,4 +129,14 @@ export type JobStatus = { /** Estimated total size of the download in bytes (optional) this is used when we * download transcoded content because we don't know the size of the file until it's downloaded */ estimatedTotalSizeBytes?: number; + /** Timestamp when the download was paused (optional) */ + pausedAt?: Date; + /** Progress percentage when download was paused (optional) */ + pausedProgress?: number; + /** Bytes downloaded when download was paused (optional) */ + pausedBytes?: number; + /** Bytes downloaded in the current session (since last resume). Used for session-only speed calculation. */ + lastSessionBytes?: number; + /** Timestamp when the session-only bytes were last updated. */ + lastSessionUpdateTime?: Date; }; diff --git a/translations/en.json b/translations/en.json index 834fd183..8578567a 100644 --- a/translations/en.json +++ b/translations/en.json @@ -223,7 +223,9 @@ "system": "System" }, "toasts": { - "error_deleting_files": "Error Deleting Files" + "error_deleting_files": "Error Deleting Files", + "background_downloads_enabled": "Background downloads enabled", + "background_downloads_disabled": "Background downloads disabled" } }, "sessions": { @@ -266,11 +268,23 @@ "download_completed": "Download Completed", "download_failed_for_item": "Download failed for {{item}} - {{error}}", "download_completed_for_item": "Download Completed for {{item}}", + "download_started_for_item": "Download Started for {{item}}", + "failed_to_start_download": "Failed to start download", "all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully", + "failed_to_clean_cache_directory": "Failed to clean cache directory", + "could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}", "go_to_downloads": "Go to Downloads" } } }, + "common": { + "select": "Select", + "no_trailer_available": "No trailer available", + "video": "Video", + "audio": "Audio", + "subtitle": "Subtitle", + "play": "Play" + }, "search": { "search": "Search...", "x_items": "{{count}} Items", diff --git a/utils/log.tsx b/utils/log.tsx index 88ca475e..956f1fb0 100644 --- a/utils/log.tsx +++ b/utils/log.tsx @@ -77,6 +77,17 @@ export const clearLogs = () => { storage.delete("logs"); }; +export const dumpDownloadDiagnostics = (extra: any = {}) => { + const diagnostics = { + timestamp: new Date().toISOString(), + processes: extra?.processes || [], + nativeTasks: extra?.nativeTasks || [], + focusedProcess: extra?.focusedProcess || null, + }; + writeDebugLog("Download diagnostics", diagnostics); + return diagnostics; +}; + export function useLog() { const context = useContext(LogContext); if (context === null) { From 0ec44add7d9513191e979a6277cc0ddcfcfb6e96 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 29 Sep 2025 15:02:24 +0200 Subject: [PATCH 11/27] fix: navigatte to item --- components/AppleTVCarousel.tsx | 99 +++++++++++++++++++++------------- 1 file changed, 63 insertions(+), 36 deletions(-) diff --git a/components/AppleTVCarousel.tsx b/components/AppleTVCarousel.tsx index 2f5b2d50..c439069d 100644 --- a/components/AppleTVCarousel.tsx +++ b/components/AppleTVCarousel.tsx @@ -7,9 +7,10 @@ import { import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { LinearGradient } from "expo-linear-gradient"; +import { useRouter } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { Dimensions, Pressable, View } from "react-native"; +import { Dimensions, Pressable, TouchableOpacity, View } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { Easing, @@ -25,6 +26,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { ItemImage } from "./common/ItemImage"; +import { getItemNavigation } from "./common/TouchableItemRouter"; import type { SelectedOptions } from "./ItemContent"; import { PlayButton } from "./PlayButton"; import { PlayedStatus } from "./PlayedStatus"; @@ -150,6 +152,7 @@ export const AppleTVCarousel: React.FC = ({ const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { isConnected, serverConnected } = useNetworkStatus(); + const router = useRouter(); const [currentIndex, setCurrentIndex] = useState(initialIndex); const translateX = useSharedValue(-currentIndex * screenWidth); @@ -301,6 +304,14 @@ export const AppleTVCarousel: React.FC = ({ [hasItems, items, onItemChange, translateX], ); + const navigateToItem = useCallback( + (item: BaseItemDto) => { + const navigation = getItemNavigation(item, "(home)"); + router.push(navigation as any); + }, + [router], + ); + const panGesture = Gesture.Pan() .activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET]) .onUpdate((event) => { @@ -591,7 +602,8 @@ export const AppleTVCarousel: React.FC = ({ {/* Logo Section */} {itemLogoUrl && ( - navigateToItem(item)} style={{ position: "absolute", bottom: LOGO_BOTTOM_POSITION, @@ -611,7 +623,7 @@ export const AppleTVCarousel: React.FC = ({ }} contentFit='contain' /> - + )} {/* Type and Genres Section */} @@ -625,41 +637,56 @@ export const AppleTVCarousel: React.FC = ({ alignItems: "center", }} > - - {(() => { - const typeLabel = - item.Type === "Series" - ? "TV Show" - : item.Type === "Movie" - ? "Movie" - : item.Type || ""; + navigateToItem(item)}> + + {(() => { + let typeLabel = ""; - const genres = - item.Genres && item.Genres.length > 0 - ? item.Genres.slice(0, MAX_GENRES_COUNT).join(" โ€ข ") - : ""; + if (item.Type === "Episode") { + // For episodes, show season and episode number + const season = item.ParentIndexNumber; + const episode = item.IndexNumber; + if (season && episode) { + typeLabel = `S${season} โ€ข E${episode}`; + } else { + typeLabel = "Episode"; + } + } else { + typeLabel = + item.Type === "Series" + ? "TV Show" + : item.Type === "Movie" + ? "Movie" + : item.Type || ""; + } - if (typeLabel && genres) { - return `${typeLabel} โ€ข ${genres}`; - } else if (typeLabel) { - return typeLabel; - } else if (genres) { - return genres; - } else { - return ""; - } - })()} - + const genres = + item.Genres && item.Genres.length > 0 + ? item.Genres.slice(0, MAX_GENRES_COUNT).join(" โ€ข ") + : ""; + + if (typeLabel && genres) { + return `${typeLabel} โ€ข ${genres}`; + } else if (typeLabel) { + return typeLabel; + } else if (genres) { + return genres; + } else { + return ""; + } + })()} + + {/* Controls Section */} From f104e952ab0b7ffc45483065e5ab014b53740285 Mon Sep 17 00:00:00 2001 From: Gauvain <68083474+Gauvino@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:37:02 +0200 Subject: [PATCH 12/27] feat: add automated PR comments for build artifacts (#1099) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/artifact-comment.yml | 124 +++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 .github/workflows/artifact-comment.yml diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml new file mode 100644 index 00000000..112f7113 --- /dev/null +++ b/.github/workflows/artifact-comment.yml @@ -0,0 +1,124 @@ +name: ๐Ÿ“ Artifact Comment on PR + +concurrency: + group: artifact-comment-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true + +on: + workflow_run: + workflows: ["๐Ÿค– iOS IPA Build (Phone + TV)", "๐Ÿค– Android APK Build (Phone + TV)"] + types: + - completed + +jobs: + comment-artifacts: + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + actions: read + + steps: + - name: ๐Ÿ” Get PR and Artifacts + uses: actions/github-script@v8 + with: + script: | + // Find PR associated with this commit + const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: "${{ github.event.workflow_run.head_sha }}" + }); + + if (pullRequests.length === 0) { + core.setFailed('No pull request found for this commit'); + return; + } + + const pr = pullRequests[0]; + const runId = "${{ github.event.workflow_run.id }}"; + + // Get artifacts from the workflow run + const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId + }); + + if (artifacts.artifacts.length === 0) { + console.log('No artifacts found for this run'); + return; + } + + // Sort and categorize artifacts + const androidArtifacts = artifacts.artifacts + .filter(a => a.name.includes('android')) + .sort((a, b) => a.name.localeCompare(b.name)); + const iosArtifacts = artifacts.artifacts + .filter(a => a.name.includes('ios')) + .sort((a, b) => a.name.localeCompare(b.name)); + + // Build comment body with table format + let commentBody = `## ๐Ÿ“ฑ Build Artifacts Ready!\n\n`; + commentBody += `โœ… **Workflow completed successfully** for PR #${pr.number}\n`; + commentBody += `๐Ÿ“ฆ **${artifacts.artifacts.length} artifacts** generated from commit [\`${pr.head.sha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${pr.head.sha})\n\n`; + + // Create table for better organization + commentBody += `| Platform | Device Type | Download Link |\n`; + commentBody += `|----------|-------------|---------------|\n`; + + // Add Android artifacts + androidArtifacts.forEach(artifact => { + const isTV = artifact.name.includes('tv'); + const deviceType = isTV ? '๐Ÿ“บ Android TV' : '๐Ÿ“ฑ Android Phone'; + const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/${artifact.name}.zip`; + commentBody += `| ๐Ÿค– Android | ${deviceType} | [๐Ÿ“ฅ Download APK](${nightlyLink}) |\n`; + }); + + // Add iOS artifacts + iosArtifacts.forEach(artifact => { + const isTV = artifact.name.includes('tv'); + const deviceType = isTV ? '๐Ÿ“บ Apple TV' : '๐Ÿ“ฑ iPhone/iPad'; + const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/${artifact.name}.zip`; + commentBody += `| ๐ŸŽ iOS | ${deviceType} | [๐Ÿ“ฅ Download IPA](${nightlyLink}) |\n`; + }); + + commentBody += `\n`; + 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`; + commentBody += `*Auto-generated by [GitHub Actions](${context.payload.workflow_run.html_url})*`; + + // Find existing bot comment to update + 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('Build Artifacts Ready!') + ); + + 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}`); + } From 7fe3ca84842c292b612e7902fe7c6ab0ea06a6d1 Mon Sep 17 00:00:00 2001 From: Gauvain <68083474+Gauvino@users.noreply.github.com> Date: Mon, 29 Sep 2025 22:31:41 +0200 Subject: [PATCH 13/27] feat: add automated PR comments for build artifacts (#1100) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/artifact-comment.yml | 103 +++++++++++++++---------- 1 file changed, 61 insertions(+), 42 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 112f7113..67e31268 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -1,12 +1,17 @@ name: ๐Ÿ“ Artifact Comment on PR concurrency: - group: artifact-comment-${{ github.event.workflow_run.head_branch }} - cancel-in-progress: true + group: artifact-comment-${{ github.event.workflow_run.id }} + cancel-in-progress: false on: + workflow_dispatch: workflow_run: - workflows: ["๐Ÿค– iOS IPA Build (Phone + TV)", "๐Ÿค– Android APK Build (Phone + TV)"] + workflows: + - "๐Ÿ—๏ธ Build iOS IPA (phone)" + - "๐Ÿ—๏ธ Build iOS IPA (tv)" + - "๐Ÿ—๏ธ Build Android APK (phone)" + - "๐Ÿ—๏ธ Build Android APK (tv)" types: - completed @@ -24,33 +29,39 @@ jobs: uses: actions/github-script@v8 with: script: | + console.log('Workflow run event:', JSON.stringify(github.event.workflow_run, null, 2)); + // Find PR associated with this commit const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ owner: context.repo.owner, repo: context.repo.repo, commit_sha: "${{ github.event.workflow_run.head_sha }}" }); - + if (pullRequests.length === 0) { - core.setFailed('No pull request found for this commit'); + console.log('No pull request found for commit: ${{ github.event.workflow_run.head_sha }}'); return; } - + const pr = pullRequests[0]; const runId = "${{ github.event.workflow_run.id }}"; - + + console.log(`Found PR #${pr.number} for commit ${pr.head.sha.substring(0, 7)}`); + // Get artifacts from the workflow run const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ owner: context.repo.owner, repo: context.repo.repo, run_id: runId }); - - if (artifacts.artifacts.length === 0) { + + console.log(`Found ${artifacts?.artifacts?.length || 0} artifacts`); + + if (!artifacts || artifacts.artifacts.length === 0) { console.log('No artifacts found for this run'); return; } - + // Sort and categorize artifacts const androidArtifacts = artifacts.artifacts .filter(a => a.name.includes('android')) @@ -58,51 +69,59 @@ jobs: const iosArtifacts = artifacts.artifacts .filter(a => a.name.includes('ios')) .sort((a, b) => a.name.localeCompare(b.name)); - + + console.log(`Android artifacts: ${androidArtifacts.length}, iOS artifacts: ${iosArtifacts.length}`); + // Build comment body with table format let commentBody = `## ๐Ÿ“ฑ Build Artifacts Ready!\n\n`; commentBody += `โœ… **Workflow completed successfully** for PR #${pr.number}\n`; commentBody += `๐Ÿ“ฆ **${artifacts.artifacts.length} artifacts** generated from commit [\`${pr.head.sha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${pr.head.sha})\n\n`; - - // Create table for better organization - commentBody += `| Platform | Device Type | Download Link |\n`; - commentBody += `|----------|-------------|---------------|\n`; - - // Add Android artifacts - androidArtifacts.forEach(artifact => { - const isTV = artifact.name.includes('tv'); - const deviceType = isTV ? '๐Ÿ“บ Android TV' : '๐Ÿ“ฑ Android Phone'; - const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/${artifact.name}.zip`; - commentBody += `| ๐Ÿค– Android | ${deviceType} | [๐Ÿ“ฅ Download APK](${nightlyLink}) |\n`; - }); - - // Add iOS artifacts - iosArtifacts.forEach(artifact => { - const isTV = artifact.name.includes('tv'); - const deviceType = isTV ? '๐Ÿ“บ Apple TV' : '๐Ÿ“ฑ iPhone/iPad'; - const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/${artifact.name}.zip`; - commentBody += `| ๐ŸŽ iOS | ${deviceType} | [๐Ÿ“ฅ Download IPA](${nightlyLink}) |\n`; - }); - - commentBody += `\n`; - 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`; - commentBody += `*Auto-generated by [GitHub Actions](${context.payload.workflow_run.html_url})*`; - + + if (androidArtifacts.length === 0 && iosArtifacts.length === 0) { + commentBody += `โš ๏ธ No mobile app artifacts found in this build.\n\n`; + } else { + // Create table for better organization + commentBody += `| Platform | Device Type | Download Link |\n`; + commentBody += `|----------|-------------|---------------|\n`; + + // Add Android artifacts + androidArtifacts.forEach(artifact => { + const isTV = artifact.name.includes('tv'); + const deviceType = isTV ? '๐Ÿ“บ Android TV' : '๐Ÿ“ฑ Android Phone'; + const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/${artifact.name}.zip`; + commentBody += `| ๐Ÿค– Android | ${deviceType} | [๐Ÿ“ฅ Download APK](${nightlyLink}) |\n`; + }); + + // Add iOS artifacts + iosArtifacts.forEach(artifact => { + const isTV = artifact.name.includes('tv'); + const deviceType = isTV ? '๐Ÿ“บ Apple TV' : '๐Ÿ“ฑ iPhone/iPad'; + const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/${artifact.name}.zip`; + commentBody += `| ๐ŸŽ iOS | ${deviceType} | [๐Ÿ“ฅ Download IPA](${nightlyLink}) |\n`; + }); + + commentBody += `\n`; + 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`; + } + + commentBody += `*Auto-generated by [GitHub Actions](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId})*`; + commentBody += `\n`; + // Find existing bot comment to update 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('Build Artifacts Ready!') + comment.body.includes('') ); - + if (botComment) { // Update existing comment await github.rest.issues.updateComment({ From 92e40c7aa0d1ca4ddff4cbfce786f9c080dea2a9 Mon Sep 17 00:00:00 2001 From: Gauvain <68083474+Gauvino@users.noreply.github.com> Date: Mon, 29 Sep 2025 23:16:12 +0200 Subject: [PATCH 14/27] feat: Build in pr (#1101) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/artifact-comment.yml | 75 +++++++++++++++++--------- 1 file changed, 51 insertions(+), 24 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 67e31268..14e74800 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -1,23 +1,21 @@ name: ๐Ÿ“ Artifact Comment on PR concurrency: - group: artifact-comment-${{ github.event.workflow_run.id }} + group: artifact-comment-${{ github.event.workflow_run.id || github.run_id }} cancel-in-progress: false on: - workflow_dispatch: + workflow_dispatch: # Allow manual testing workflow_run: workflows: - - "๐Ÿ—๏ธ Build iOS IPA (phone)" - - "๐Ÿ—๏ธ Build iOS IPA (tv)" - - "๐Ÿ—๏ธ Build Android APK (phone)" - - "๐Ÿ—๏ธ Build Android APK (tv)" + - "๐Ÿค– Android APK Build (Phone + TV)" + - "๐Ÿค– iOS IPA Build (Phone + TV)" types: - completed jobs: comment-artifacts: - if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success') runs-on: ubuntu-latest permissions: contents: read @@ -29,23 +27,56 @@ jobs: uses: actions/github-script@v8 with: script: | - console.log('Workflow run event:', JSON.stringify(github.event.workflow_run, null, 2)); + // Handle both workflow_run and manual dispatch events + let runId, pr; - // Find PR associated with this commit - const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ - owner: context.repo.owner, - repo: context.repo.repo, - commit_sha: "${{ github.event.workflow_run.head_sha }}" - }); - - if (pullRequests.length === 0) { - console.log('No pull request found for commit: ${{ github.event.workflow_run.head_sha }}'); + if (context.eventName === 'workflow_run') { + runId = github.event.workflow_run.id; + + // Find PR associated with this commit + const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: github.event.workflow_run.head_sha + }); + + if (pullRequests.length === 0) { + console.log('No pull request found for commit:', github.event.workflow_run.head_sha); + return; + } + pr = pullRequests[0]; + + } else if (context.eventName === 'workflow_dispatch') { + // For manual testing, use most recent test workflow run + const { data: workflows } = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'test-artifact.yml', + per_page: 1 + }); + + if (workflows.workflow_runs.length === 0) { + console.log('No test workflow runs found'); + return; + } + + const testRun = workflows.workflow_runs[0]; + runId = testRun.id; + + // Get current PR for manual testing + const prNumber = context.payload.pull_request?.number || 1101; + const { data: prData } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + pr = prData; + + } else { + console.log('Unsupported event type:', context.eventName); return; } - const pr = pullRequests[0]; - const runId = "${{ github.event.workflow_run.id }}"; - console.log(`Found PR #${pr.number} for commit ${pr.head.sha.substring(0, 7)}`); // Get artifacts from the workflow run @@ -55,8 +86,6 @@ jobs: run_id: runId }); - console.log(`Found ${artifacts?.artifacts?.length || 0} artifacts`); - if (!artifacts || artifacts.artifacts.length === 0) { console.log('No artifacts found for this run'); return; @@ -70,8 +99,6 @@ jobs: .filter(a => a.name.includes('ios')) .sort((a, b) => a.name.localeCompare(b.name)); - console.log(`Android artifacts: ${androidArtifacts.length}, iOS artifacts: ${iosArtifacts.length}`); - // Build comment body with table format let commentBody = `## ๐Ÿ“ฑ Build Artifacts Ready!\n\n`; commentBody += `โœ… **Workflow completed successfully** for PR #${pr.number}\n`; From 1ff09a2d34e7ac2e71c749deaef0ce8cf8d258fe Mon Sep 17 00:00:00 2001 From: Uruk Date: Mon, 29 Sep 2025 23:51:03 +0200 Subject: [PATCH 15/27] feat: enhance artifact workflow to show real-time build status Improves the artifact comment workflow to provide better visibility into ongoing builds by: - Triggering comments when builds start (requested event) instead of only on completion - Using commit SHA for concurrency grouping to better handle multiple builds for the same commit - Collecting artifacts from all recent build workflows for a PR rather than just the current run - Adding a build status table showing progress of Android and iOS workflows - Displaying progressive status updates even when builds are still in progress - Enabling cancel-in-progress to prevent redundant workflow runs This provides users with immediate feedback on build progress and comprehensive artifact availability across all platforms. --- .github/workflows/artifact-comment.yml | 128 +++++++++++++++++-------- 1 file changed, 87 insertions(+), 41 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 14e74800..60244409 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -1,8 +1,8 @@ name: ๐Ÿ“ Artifact Comment on PR concurrency: - group: artifact-comment-${{ github.event.workflow_run.id || github.run_id }} - cancel-in-progress: false + group: artifact-comment-${{ github.event.workflow_run.head_sha || github.sha }} + cancel-in-progress: true on: workflow_dispatch: # Allow manual testing @@ -12,10 +12,11 @@ on: - "๐Ÿค– iOS IPA Build (Phone + TV)" types: - completed + - requested # Trigger when build starts jobs: comment-artifacts: - if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success') + if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'pull_request' runs-on: ubuntu-latest permissions: contents: read @@ -28,11 +29,9 @@ jobs: with: script: | // Handle both workflow_run and manual dispatch events - let runId, pr; + let pr; if (context.eventName === 'workflow_run') { - runId = github.event.workflow_run.id; - // Find PR associated with this commit const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ owner: context.repo.owner, @@ -47,22 +46,6 @@ jobs: pr = pullRequests[0]; } else if (context.eventName === 'workflow_dispatch') { - // For manual testing, use most recent test workflow run - const { data: workflows } = await github.rest.actions.listWorkflowRuns({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'test-artifact.yml', - per_page: 1 - }); - - if (workflows.workflow_runs.length === 0) { - console.log('No test workflow runs found'); - return; - } - - const testRun = workflows.workflow_runs[0]; - runId = testRun.id; - // Get current PR for manual testing const prNumber = context.payload.pull_request?.number || 1101; const { data: prData } = await github.rest.pulls.get({ @@ -77,36 +60,97 @@ jobs: return; } - console.log(`Found PR #${pr.number} for commit ${pr.head.sha.substring(0, 7)}`); + console.log(`Processing PR #${pr.number} for commit ${pr.head.sha.substring(0, 7)}`); - // Get artifacts from the workflow run + // Get all recent workflow runs for this PR to collect artifacts from multiple builds + const { data: workflowRuns } = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + head_sha: pr.head.sha, + per_page: 10 + }); + + // Filter for build workflows only + const buildRuns = workflowRuns.workflow_runs.filter(run => + run.name.includes('Android APK Build') || + run.name.includes('iOS IPA Build') + ); + + console.log(`Found ${buildRuns.length} build workflow runs for this commit`); + + // Collect artifacts from all completed successful builds + let allArtifacts = []; + let buildStatuses = {}; + + for (const run of buildRuns) { + buildStatuses[run.name] = { + status: run.status, + conclusion: run.conclusion, + url: run.html_url, + runId: run.id + }; + + if (run.conclusion === 'success') { + try { + const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: run.id + }); + allArtifacts.push(...artifacts.artifacts); + } catch (error) { + console.log(`Failed to get artifacts for run ${run.id}:`, error.message); + } + } + } + + console.log(`Collected ${allArtifacts.length} total artifacts from all builds`); + + // Get artifacts from current run if needed const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ owner: context.repo.owner, repo: context.repo.repo, run_id: runId }); - if (!artifacts || artifacts.artifacts.length === 0) { - console.log('No artifacts found for this run'); - return; - } - - // Sort and categorize artifacts - const androidArtifacts = artifacts.artifacts + // Sort and categorize all collected artifacts + const androidArtifacts = allArtifacts .filter(a => a.name.includes('android')) .sort((a, b) => a.name.localeCompare(b.name)); - const iosArtifacts = artifacts.artifacts + const iosArtifacts = allArtifacts .filter(a => a.name.includes('ios')) .sort((a, b) => a.name.localeCompare(b.name)); - // Build comment body with table format - let commentBody = `## ๐Ÿ“ฑ Build Artifacts Ready!\n\n`; - commentBody += `โœ… **Workflow completed successfully** for PR #${pr.number}\n`; - commentBody += `๐Ÿ“ฆ **${artifacts.artifacts.length} artifacts** generated from commit [\`${pr.head.sha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${pr.head.sha})\n\n`; + // Build comment body with progressive status + let commentBody = `## ๐Ÿ“ฑ Build Status for PR #${pr.number}\n\n`; + commentBody += `๐Ÿ”— **Commit**: [\`${pr.head.sha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${pr.head.sha})\n\n`; + + // Add build status table + commentBody += `### ๐Ÿ”ง Build Status\n\n`; + commentBody += `| Workflow | Status | Artifacts |\n`; + commentBody += `|----------|--------|-----------|\n`; + + for (const [name, status] of Object.entries(buildStatuses)) { + const emoji = status.conclusion === 'success' ? 'โœ…' : + status.conclusion === 'failure' ? 'โŒ' : + status.status === 'in_progress' ? '๐Ÿ”„' : 'โณ'; + const statusText = status.conclusion || status.status || 'pending'; + const artifactCount = allArtifacts.filter(a => { + // Match artifacts to workflows based on naming patterns + if (name.includes('Android')) return a.name.includes('android'); + if (name.includes('iOS')) return a.name.includes('ios'); + return false; + }).length; + + commentBody += `| [${name}](${status.url}) | ${emoji} ${statusText} | ${artifactCount} |\n`; + } + + commentBody += `\n`; + + // Only show download table if there are artifacts + if (allArtifacts.length > 0) { + commentBody += `### ๐Ÿ“ฆ Available Downloads (${allArtifacts.length} artifacts)\n\n`; - if (androidArtifacts.length === 0 && iosArtifacts.length === 0) { - commentBody += `โš ๏ธ No mobile app artifacts found in this build.\n\n`; - } else { // Create table for better organization commentBody += `| Platform | Device Type | Download Link |\n`; commentBody += `|----------|-------------|---------------|\n`; @@ -115,7 +159,7 @@ jobs: androidArtifacts.forEach(artifact => { const isTV = artifact.name.includes('tv'); const deviceType = isTV ? '๐Ÿ“บ Android TV' : '๐Ÿ“ฑ Android Phone'; - const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/${artifact.name}.zip`; + const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${artifact.workflow_run.id}/${artifact.name}.zip`; commentBody += `| ๐Ÿค– Android | ${deviceType} | [๐Ÿ“ฅ Download APK](${nightlyLink}) |\n`; }); @@ -123,7 +167,7 @@ jobs: iosArtifacts.forEach(artifact => { const isTV = artifact.name.includes('tv'); const deviceType = isTV ? '๐Ÿ“บ Apple TV' : '๐Ÿ“ฑ iPhone/iPad'; - const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/${artifact.name}.zip`; + const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${artifact.workflow_run.id}/${artifact.name}.zip`; commentBody += `| ๐ŸŽ iOS | ${deviceType} | [๐Ÿ“ฅ Download IPA](${nightlyLink}) |\n`; }); @@ -132,6 +176,8 @@ jobs: 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 += `โณ **No artifacts available yet** - builds are still in progress or haven't completed successfully.\n\n`; } commentBody += `*Auto-generated by [GitHub Actions](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId})*`; From 0d1aeaf8aaeea68f5dbb2f8a4a32eebb374db7be Mon Sep 17 00:00:00 2001 From: Uruk Date: Mon, 29 Sep 2025 23:55:36 +0200 Subject: [PATCH 16/27] fix(ci): improve workflow run condition and artifact collection Fixes workflow trigger condition by explicitly checking for workflow_run event type to prevent unintended executions. Improves artifact collection reliability by switching to the correct API method and increasing page size to capture more artifacts from multiple builds. Removes redundant artifact fetching logic that was duplicating collection efforts. --- .github/workflows/artifact-comment.yml | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 60244409..f7d5ff04 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -16,7 +16,7 @@ on: jobs: comment-artifacts: - if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'pull_request' + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request') runs-on: ubuntu-latest permissions: contents: read @@ -63,11 +63,11 @@ jobs: console.log(`Processing PR #${pr.number} for commit ${pr.head.sha.substring(0, 7)}`); // Get all recent workflow runs for this PR to collect artifacts from multiple builds - const { data: workflowRuns } = await github.rest.actions.listWorkflowRuns({ + const { data: workflowRuns } = await github.rest.actions.listWorkflowRunsForRepo({ owner: context.repo.owner, repo: context.repo.repo, head_sha: pr.head.sha, - per_page: 10 + per_page: 20 }); // Filter for build workflows only @@ -106,13 +106,6 @@ jobs: console.log(`Collected ${allArtifacts.length} total artifacts from all builds`); - // Get artifacts from current run if needed - const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: runId - }); - // Sort and categorize all collected artifacts const androidArtifacts = allArtifacts .filter(a => a.name.includes('android')) From 4f6863f31736f3bc5b0378d12538c101a6908963 Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 00:01:15 +0200 Subject: [PATCH 17/27] feat: add direct pull request trigger to artifact comment workflow Enables the workflow to run directly on pull request events (opened, synchronize, reopened) in addition to the existing workflow_run and manual dispatch triggers. Provides immediate status updates in PR checks and improves user experience by showing workflow progress directly in the pull request interface rather than only after completion of upstream workflows. --- .github/workflows/artifact-comment.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index f7d5ff04..4d7df677 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -6,6 +6,8 @@ concurrency: on: workflow_dispatch: # Allow manual testing + pull_request: # Show in PR checks and provide status updates + types: [opened, synchronize, reopened] workflow_run: workflows: - "๐Ÿค– Android APK Build (Phone + TV)" @@ -16,7 +18,7 @@ on: jobs: comment-artifacts: - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request') + if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request') runs-on: ubuntu-latest permissions: contents: read @@ -28,7 +30,7 @@ jobs: uses: actions/github-script@v8 with: script: | - // Handle both workflow_run and manual dispatch events + // Handle workflow_run, pull_request, and manual dispatch events let pr; if (context.eventName === 'workflow_run') { @@ -45,6 +47,10 @@ jobs: } pr = pullRequests[0]; + } else if (context.eventName === 'pull_request') { + // Direct PR event + pr = context.payload.pull_request; + } else if (context.eventName === 'workflow_dispatch') { // Get current PR for manual testing const prNumber = context.payload.pull_request?.number || 1101; From 5b0d2f3f7bf158c019106edcb78be97ce6b89e02 Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 00:03:40 +0200 Subject: [PATCH 18/27] fix(ci): correct GitHub Actions run ID reference Fixes incorrect variable reference in artifact comment generation that was using undefined `runId` instead of the proper `context.runId` from the GitHub Actions context. Also adds descriptive job name for better workflow visibility. --- .github/workflows/artifact-comment.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 4d7df677..1e63db57 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -19,6 +19,7 @@ on: 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-latest permissions: contents: read @@ -179,7 +180,7 @@ jobs: commentBody += `โณ **No artifacts available yet** - builds are still in progress or haven't completed successfully.\n\n`; } - commentBody += `*Auto-generated by [GitHub Actions](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId})*`; + commentBody += `*Auto-generated by [GitHub Actions](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})*`; commentBody += `\n`; // Find existing bot comment to update From e985adf06226e06fb58a2cca6b246ed6145a63cf Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 00:22:51 +0200 Subject: [PATCH 19/27] feat: improve build status UI with progressive updates Restructures the artifact comment workflow to display build progress in real-time with individual platform/device status tracking. Changes the status table from workflow-based to target-based (Android Phone/TV, iOS Phone/TV) with dedicated status indicators and download links that update as builds complete. Improves user experience by showing pending builds with appropriate messaging instead of waiting for all builds to finish before displaying any information. --- .github/workflows/artifact-comment.yml | 111 +++++++++++++------------ 1 file changed, 59 insertions(+), 52 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 1e63db57..6a36d0f5 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -85,10 +85,18 @@ jobs: console.log(`Found ${buildRuns.length} build workflow runs for this commit`); - // Collect artifacts from all completed successful builds + // Collect artifacts and statuses from all builds (completed and in-progress) let allArtifacts = []; let buildStatuses = {}; + // Define all expected build targets + const expectedBuilds = { + 'Android Phone': { platform: 'Android', device: 'Phone', emoji: '๐Ÿ“ฑ', pattern: 'android.*phone' }, + 'Android TV': { platform: 'Android', device: 'TV', emoji: '๐Ÿ“บ', pattern: 'android.*tv' }, + 'iOS Phone': { platform: 'iOS', device: 'Phone', emoji: '๐Ÿ“ฑ', pattern: 'ios.*phone' }, + 'iOS TV': { platform: 'iOS', device: 'TV', emoji: '๐Ÿ“บ', pattern: 'ios.*tv' } + }; + for (const run of buildRuns) { buildStatuses[run.name] = { status: run.status, @@ -97,6 +105,7 @@ jobs: runId: run.id }; + // Collect artifacts from any completed successful builds if (run.conclusion === 'success') { try { const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ @@ -113,71 +122,69 @@ jobs: console.log(`Collected ${allArtifacts.length} total artifacts from all builds`); - // Sort and categorize all collected artifacts - const androidArtifacts = allArtifacts - .filter(a => a.name.includes('android')) - .sort((a, b) => a.name.localeCompare(b.name)); - const iosArtifacts = allArtifacts - .filter(a => a.name.includes('ios')) - .sort((a, b) => a.name.localeCompare(b.name)); - - // Build comment body with progressive status + // Build comment body with progressive status for individual builds let commentBody = `## ๐Ÿ“ฑ Build Status for PR #${pr.number}\n\n`; commentBody += `๐Ÿ”— **Commit**: [\`${pr.head.sha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${pr.head.sha})\n\n`; - // Add build status table - commentBody += `### ๐Ÿ”ง Build Status\n\n`; - commentBody += `| Workflow | Status | Artifacts |\n`; - commentBody += `|----------|--------|-----------|\n`; + // Progressive build status and downloads table + commentBody += `### ๏ฟฝ Build Artifacts\n\n`; + commentBody += `| Platform | Device | Status | Download |\n`; + commentBody += `|----------|--------|--------|---------|\n`; - for (const [name, status] of Object.entries(buildStatuses)) { - const emoji = status.conclusion === 'success' ? 'โœ…' : - status.conclusion === 'failure' ? 'โŒ' : - status.status === 'in_progress' ? '๐Ÿ”„' : 'โณ'; - const statusText = status.conclusion || status.status || 'pending'; - const artifactCount = allArtifacts.filter(a => { - // Match artifacts to workflows based on naming patterns - if (name.includes('Android')) return a.name.includes('android'); - if (name.includes('iOS')) return a.name.includes('ios'); - return false; - }).length; - - commentBody += `| [${name}](${status.url}) | ${emoji} ${statusText} | ${artifactCount} |\n`; + // Process each expected build target individually + const buildTargets = [ + { name: 'Android Phone', platform: '๐Ÿค–', device: '๐Ÿ“ฑ', pattern: /android.*phone/i }, + { name: 'Android TV', platform: '๐Ÿค–', device: '๐Ÿ“บ', pattern: /android.*tv/i }, + { name: 'iOS Phone', platform: '๐ŸŽ', device: '๏ฟฝ', pattern: /ios.*phone/i }, + { name: 'iOS TV', platform: '๐ŸŽ', device: '๐Ÿ“บ', pattern: /ios.*tv/i } + ]; + + for (const target of buildTargets) { + // Find matching workflow run + const matchingRun = buildRuns.find(run => { + return (run.name.includes('Android') && target.name.includes('Android')) || + (run.name.includes('iOS') && target.name.includes('iOS')); + }); + + // Find matching artifact + const matchingArtifact = allArtifacts.find(artifact => + target.pattern.test(artifact.name) + ); + + let status = 'โณ Pending'; + let downloadLink = '*Waiting for build...*'; + + if (matchingRun) { + if (matchingRun.conclusion === 'success' && matchingArtifact) { + status = 'โœ… Complete'; + const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/${matchingArtifact.name}.zip`; + const fileType = target.name.includes('Android') ? 'APK' : 'IPA'; + downloadLink = `[๐Ÿ“ฅ Download ${fileType}](${nightlyLink})`; + } else if (matchingRun.conclusion === 'failure') { + status = `โŒ [Failed](${matchingRun.html_url})`; + downloadLink = '*Build failed*'; + } else if (matchingRun.status === 'in_progress') { + status = `๐Ÿ”„ [Building...](${matchingRun.html_url})`; + downloadLink = '*Build in progress...*'; + } else if (matchingRun.status === 'queued') { + status = `โณ [Queued](${matchingRun.html_url})`; + downloadLink = '*Waiting to start...*'; + } + } + + commentBody += `| ${target.platform} ${target.name.split(' ')[0]} | ${target.device} ${target.name.split(' ')[1]} | ${status} | ${downloadLink} |\n`; } commentBody += `\n`; - // Only show download table if there are artifacts + // Show installation instructions if we have any artifacts if (allArtifacts.length > 0) { - commentBody += `### ๐Ÿ“ฆ Available Downloads (${allArtifacts.length} artifacts)\n\n`; - - // Create table for better organization - commentBody += `| Platform | Device Type | Download Link |\n`; - commentBody += `|----------|-------------|---------------|\n`; - - // Add Android artifacts - androidArtifacts.forEach(artifact => { - const isTV = artifact.name.includes('tv'); - const deviceType = isTV ? '๐Ÿ“บ Android TV' : '๐Ÿ“ฑ Android Phone'; - const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${artifact.workflow_run.id}/${artifact.name}.zip`; - commentBody += `| ๐Ÿค– Android | ${deviceType} | [๐Ÿ“ฅ Download APK](${nightlyLink}) |\n`; - }); - - // Add iOS artifacts - iosArtifacts.forEach(artifact => { - const isTV = artifact.name.includes('tv'); - const deviceType = isTV ? '๐Ÿ“บ Apple TV' : '๐Ÿ“ฑ iPhone/iPad'; - const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${artifact.workflow_run.id}/${artifact.name}.zip`; - commentBody += `| ๐ŸŽ iOS | ${deviceType} | [๐Ÿ“ฅ Download IPA](${nightlyLink}) |\n`; - }); - - commentBody += `\n`; 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 += `โณ **No artifacts available yet** - builds are still in progress or haven't completed successfully.\n\n`; + 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})*`; From 44e489f40c66deac09ae9e93d5640301aff57496 Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 00:29:13 +0200 Subject: [PATCH 20/27] refactor: improve workflow run tracking and status display Enhances the artifact comment workflow by switching from tracking all build runs to focusing on the most recent run per workflow type (Android/iOS). Changes include: - Increases pagination limit to capture more workflow runs - Sorts runs by creation time to identify latest builds - Simplifies status tracking by workflow platform rather than individual runs - Adds detailed logging for debugging build statuses - Improves error handling for artifact collection - Fixes emoji rendering issue in status display Reduces complexity while ensuring accurate status reporting for the latest builds. --- .github/workflows/artifact-comment.yml | 112 ++++++++++++++++--------- 1 file changed, 72 insertions(+), 40 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 6a36d0f5..1aa981f1 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -74,48 +74,79 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, head_sha: pr.head.sha, - per_page: 20 + per_page: 30 }); - // Filter for build workflows only - const buildRuns = workflowRuns.workflow_runs.filter(run => - run.name.includes('Android APK Build') || - run.name.includes('iOS IPA Build') - ); + // Filter for build workflows only and sort by creation time (most recent first) + const buildRuns = workflowRuns.workflow_runs + .filter(run => + 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`); - // Collect artifacts and statuses from all builds (completed and in-progress) + // 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 - get most recent run for each workflow type let allArtifacts = []; let buildStatuses = {}; - // Define all expected build targets - const expectedBuilds = { - 'Android Phone': { platform: 'Android', device: 'Phone', emoji: '๐Ÿ“ฑ', pattern: 'android.*phone' }, - 'Android TV': { platform: 'Android', device: 'TV', emoji: '๐Ÿ“บ', pattern: 'android.*tv' }, - 'iOS Phone': { platform: 'iOS', device: 'Phone', emoji: '๐Ÿ“ฑ', pattern: 'ios.*phone' }, - 'iOS TV': { platform: 'iOS', device: 'TV', emoji: '๐Ÿ“บ', pattern: 'ios.*tv' } - }; + // Get the most recent run for each workflow type + const latestAndroidRun = buildRuns.find(run => run.name.includes('Android APK Build')); + const latestIOSRun = buildRuns.find(run => run.name.includes('iOS IPA Build')); - for (const run of buildRuns) { - buildStatuses[run.name] = { - status: run.status, - conclusion: run.conclusion, - url: run.html_url, - runId: run.id + // Store status for each workflow type + 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 }; - // Collect artifacts from any completed successful builds - if (run.conclusion === 'success') { + // Collect artifacts if completed successfully + if (latestAndroidRun.conclusion === 'success') { try { const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ owner: context.repo.owner, repo: context.repo.repo, - run_id: run.id + run_id: latestAndroidRun.id }); allArtifacts.push(...artifacts.artifacts); } catch (error) { - console.log(`Failed to get artifacts for run ${run.id}:`, error.message); + 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 + }; + + // Collect artifacts if completed successfully + 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); } } } @@ -123,11 +154,11 @@ jobs: console.log(`Collected ${allArtifacts.length} total artifacts from all builds`); // Build comment body with progressive status for individual builds - let commentBody = `## ๐Ÿ“ฑ Build Status for PR #${pr.number}\n\n`; + let commentBody = `## ๐Ÿ”ง Build Status for PR #${pr.number}\n\n`; commentBody += `๐Ÿ”— **Commit**: [\`${pr.head.sha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${pr.head.sha})\n\n`; // Progressive build status and downloads table - commentBody += `### ๏ฟฝ Build Artifacts\n\n`; + commentBody += `### ๐Ÿ“ฆ Build Artifacts\n\n`; commentBody += `| Platform | Device | Status | Download |\n`; commentBody += `|----------|--------|--------|---------|\n`; @@ -135,16 +166,13 @@ jobs: const buildTargets = [ { name: 'Android Phone', platform: '๐Ÿค–', device: '๐Ÿ“ฑ', pattern: /android.*phone/i }, { name: 'Android TV', platform: '๐Ÿค–', device: '๐Ÿ“บ', pattern: /android.*tv/i }, - { name: 'iOS Phone', platform: '๐ŸŽ', device: '๏ฟฝ', pattern: /ios.*phone/i }, + { name: 'iOS Phone', platform: '๐ŸŽ', device: '๐Ÿ“ฑ', pattern: /ios.*phone/i }, { name: 'iOS TV', platform: '๐ŸŽ', device: '๐Ÿ“บ', pattern: /ios.*tv/i } ]; for (const target of buildTargets) { - // Find matching workflow run - const matchingRun = buildRuns.find(run => { - return (run.name.includes('Android') && target.name.includes('Android')) || - (run.name.includes('iOS') && target.name.includes('iOS')); - }); + // Find matching workflow status (using our simplified structure) + const matchingStatus = target.name.includes('Android') ? buildStatuses['Android'] : buildStatuses['iOS']; // Find matching artifact const matchingArtifact = allArtifacts.find(artifact => @@ -154,21 +182,25 @@ jobs: let status = 'โณ Pending'; let downloadLink = '*Waiting for build...*'; - if (matchingRun) { - if (matchingRun.conclusion === 'success' && matchingArtifact) { + if (matchingStatus) { + if (matchingStatus.conclusion === 'success' && matchingArtifact) { status = 'โœ… Complete'; const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/${matchingArtifact.name}.zip`; const fileType = target.name.includes('Android') ? 'APK' : 'IPA'; downloadLink = `[๐Ÿ“ฅ Download ${fileType}](${nightlyLink})`; - } else if (matchingRun.conclusion === 'failure') { - status = `โŒ [Failed](${matchingRun.html_url})`; + } else if (matchingStatus.conclusion === 'failure') { + status = `โŒ [Failed](${matchingStatus.url})`; downloadLink = '*Build failed*'; - } else if (matchingRun.status === 'in_progress') { - status = `๐Ÿ”„ [Building...](${matchingRun.html_url})`; + } else if (matchingStatus.status === 'in_progress') { + status = `๐Ÿ”„ [Building...](${matchingStatus.url})`; downloadLink = '*Build in progress...*'; - } else if (matchingRun.status === 'queued') { - status = `โณ [Queued](${matchingRun.html_url})`; + } else if (matchingStatus.status === 'queued') { + status = `โณ [Queued](${matchingStatus.url})`; downloadLink = '*Waiting to start...*'; + } else { + // Show any other status with timestamp for debugging + status = `๐Ÿ”„ [${matchingStatus.status}](${matchingStatus.url})`; + downloadLink = `*Status: ${matchingStatus.status}*`; } } From 2b761f15c82a0ae522bb3e3944f896aef4bf3846 Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 00:51:02 +0200 Subject: [PATCH 21/27] feat!: replace workflow_run with repository_dispatch for real-time build status updates Replaces the workflow_run trigger mechanism with repository_dispatch events to enable real-time build status communication between build workflows and the artifact comment system. Build workflows now actively notify the comment workflow when builds start, complete, or fail, providing immediate status updates rather than polling for completed workflows. Adds real-time payload processing to display current build status and target information in PR comments, improving visibility into ongoing build processes. BREAKING CHANGE: Changes the trigger mechanism from workflow_run to repository_dispatch, requiring build workflows to explicitly send status notifications. --- .github/workflows/artifact-comment.yml | 110 ++++++++++++++++++------- .github/workflows/build-android.yml | 61 ++++++++++++++ .github/workflows/build-ios.yml | 67 ++++++++++++++- 3 files changed, 207 insertions(+), 31 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 1aa981f1..0bfb99af 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -1,56 +1,61 @@ name: ๐Ÿ“ Artifact Comment on PR concurrency: - group: artifact-comment-${{ github.event.workflow_run.head_sha || github.sha }} + group: artifact-comment-${{ 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: - workflows: - - "๐Ÿค– Android APK Build (Phone + TV)" - - "๐Ÿค– iOS IPA Build (Phone + TV)" + repository_dispatch: # Triggered by build workflows when they start/complete types: - - completed - - requested # Trigger when build starts + - build-started + - build-completed + - build-failed 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') + if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || github.event_name == 'repository_dispatch' name: ๐Ÿ“ฆ Post Build Artifacts runs-on: ubuntu-latest permissions: contents: read pull-requests: write actions: read + repository-projects: read steps: - name: ๐Ÿ” Get PR and Artifacts uses: actions/github-script@v8 with: script: | - // Handle workflow_run, pull_request, and manual dispatch events + // Handle repository_dispatch, pull_request, and manual dispatch events let pr; + let targetCommitSha; - if (context.eventName === 'workflow_run') { - // Find PR associated with this commit - const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ - owner: context.repo.owner, - repo: context.repo.repo, - commit_sha: github.event.workflow_run.head_sha - }); + if (context.eventName === 'repository_dispatch') { + // Triggered by build workflows - get PR info from payload + const payload = context.payload.client_payload; + console.log('Repository dispatch payload:', JSON.stringify(payload, null, 2)); - if (pullRequests.length === 0) { - console.log('No pull request found for commit:', github.event.workflow_run.head_sha); + if (!payload || !payload.pr_number) { + console.log('No PR information in repository_dispatch payload'); return; } - pr = pullRequests[0]; + + const { data: prData } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: payload.pr_number + }); + pr = prData; + targetCommitSha = payload.commit_sha || pr.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') { // Get current PR for manual testing @@ -61,19 +66,20 @@ jobs: pull_number: prNumber }); pr = prData; + targetCommitSha = pr.head.sha; } else { console.log('Unsupported event type:', context.eventName); return; } - console.log(`Processing PR #${pr.number} for commit ${pr.head.sha.substring(0, 7)}`); + 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: pr.head.sha, + head_sha: targetCommitSha, per_page: 30 }); @@ -151,11 +157,50 @@ jobs: } } + // Override with real-time data from repository_dispatch if available + if (context.eventName === 'repository_dispatch') { + const payload = context.payload.client_payload; + const workflowType = payload.workflow_name.includes('Android') ? 'Android' : 'iOS'; + + if (buildStatuses[workflowType]) { + // Update the existing status with real-time data + buildStatuses[workflowType].status = payload.status === 'in_progress' ? 'in_progress' : + payload.status === 'success' ? 'completed' : + payload.status === 'failure' ? 'completed' : + buildStatuses[workflowType].status; + buildStatuses[workflowType].conclusion = payload.status === 'success' ? 'success' : + payload.status === 'failure' ? 'failure' : + buildStatuses[workflowType].conclusion; + buildStatuses[workflowType].url = payload.run_url; + buildStatuses[workflowType].target = payload.target; + } else { + // Create new status entry for real-time updates + buildStatuses[workflowType] = { + name: payload.workflow_name, + status: payload.status === 'in_progress' ? 'in_progress' : + payload.status === 'success' ? 'completed' : + payload.status === 'failure' ? 'completed' : 'queued', + conclusion: payload.status === 'success' ? 'success' : + payload.status === 'failure' ? 'failure' : null, + url: payload.run_url, + runId: payload.run_id, + target: payload.target, + created_at: new Date().toISOString() + }; + } + } + console.log(`Collected ${allArtifacts.length} total artifacts from all builds`); // Build comment body with progressive status for individual builds let commentBody = `## ๐Ÿ”ง Build Status for PR #${pr.number}\n\n`; - commentBody += `๐Ÿ”— **Commit**: [\`${pr.head.sha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${pr.head.sha})\n\n`; + commentBody += `๐Ÿ”— **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; + + // Show event context for debugging (only for repository_dispatch) + if (context.eventName === 'repository_dispatch') { + const payload = context.payload.client_payload; + commentBody += `๐Ÿ”” **Real-time Update**: ${payload.workflow_name} (${payload.target}) - ${payload.status}\n\n`; + } // Progressive build status and downloads table commentBody += `### ๐Ÿ“ฆ Build Artifacts\n\n`; @@ -164,15 +209,24 @@ jobs: // Process each expected build target individually const buildTargets = [ - { name: 'Android Phone', platform: '๐Ÿค–', device: '๐Ÿ“ฑ', pattern: /android.*phone/i }, - { name: 'Android TV', platform: '๐Ÿค–', device: '๐Ÿ“บ', pattern: /android.*tv/i }, - { name: 'iOS Phone', platform: '๐ŸŽ', device: '๐Ÿ“ฑ', pattern: /ios.*phone/i }, - { name: 'iOS TV', platform: '๐ŸŽ', device: '๐Ÿ“บ', pattern: /ios.*tv/i } + { name: 'Android Phone', platform: '๐Ÿค–', device: '๐Ÿ“ฑ', workflowType: 'Android', target: 'phone' }, + { name: 'Android TV', platform: '๐Ÿค–', device: '๐Ÿ“บ', workflowType: 'Android', target: 'tv' }, + { name: 'iOS Phone', platform: '๐ŸŽ', device: '๐Ÿ“ฑ', workflowType: 'iOS', target: 'phone' }, + { name: 'iOS TV', platform: '๐ŸŽ', device: '๐Ÿ“บ', workflowType: 'iOS', target: 'tv' } ]; for (const target of buildTargets) { - // Find matching workflow status (using our simplified structure) - const matchingStatus = target.name.includes('Android') ? buildStatuses['Android'] : buildStatuses['iOS']; + // Find matching workflow status + let matchingStatus = buildStatuses[target.workflowType]; + + // For repository_dispatch events, check if this specific target matches + if (context.eventName === 'repository_dispatch' && matchingStatus) { + const payload = context.payload.client_payload; + if (payload.target !== target.target) { + // This update is for a different target, show default status + matchingStatus = null; + } + } // Find matching artifact const matchingArtifact = allArtifacts.find(artifact => diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index bc9b9ea1..d273f616 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -18,6 +18,7 @@ jobs: name: ๐Ÿ—๏ธ Build Android APK permissions: contents: read + repository-projects: write strategy: fail-fast: false @@ -25,6 +26,26 @@ jobs: target: [phone, tv] steps: + - name: ๐Ÿ“ข Notify artifact comment workflow (started) + if: github.event_name == 'pull_request' + uses: actions/github-script@v8 + with: + script: | + await github.rest.repos.createDispatchEvent({ + owner: context.repo.owner, + repo: context.repo.repo, + event_type: 'build-started', + client_payload: { + workflow_name: 'Android APK Build', + target: '${{ matrix.target }}', + status: 'in_progress', + pr_number: ${{ github.event.pull_request.number }}, + commit_sha: '${{ github.event.pull_request.head.sha }}', + run_id: ${{ github.run_id }}, + run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + } + }); + - name: ๐Ÿ“ฅ Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: @@ -91,3 +112,43 @@ jobs: path: | android/app/build/outputs/apk/release/*.apk retention-days: 7 + + - name: ๐Ÿ”” Notify artifact comment workflow (success) + if: success() && github.event_name == 'pull_request' + uses: actions/github-script@v8 + with: + script: | + await github.rest.repos.createDispatchEvent({ + owner: context.repo.owner, + repo: context.repo.repo, + event_type: 'build-completed', + client_payload: { + workflow_name: 'Android APK Build', + target: '${{ matrix.target }}', + status: 'success', + pr_number: ${{ github.event.pull_request.number }}, + commit_sha: '${{ github.event.pull_request.head.sha }}', + run_id: ${{ github.run_id }}, + run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + } + }); + + - name: ๐Ÿ”” Notify artifact comment workflow (failure) + if: failure() && github.event_name == 'pull_request' + uses: actions/github-script@v8 + with: + script: | + await github.rest.repos.createDispatchEvent({ + owner: context.repo.owner, + repo: context.repo.repo, + event_type: 'build-failed', + client_payload: { + workflow_name: 'Android APK Build', + target: '${{ matrix.target }}', + status: 'failure', + pr_number: ${{ github.event.pull_request.number }}, + commit_sha: '${{ github.event.pull_request.head.sha }}', + run_id: ${{ github.run_id }}, + run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + } + }); diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 98b587b3..27c5be6c 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -9,11 +9,11 @@ on: pull_request: branches: [develop, master] paths-ignore: - - '*.md' + - "*.md" push: branches: [develop, master] paths-ignore: - - '*.md' + - "*.md" jobs: build-ios: @@ -22,14 +22,35 @@ jobs: name: ๐Ÿ—๏ธ Build iOS IPA permissions: contents: read + repository-projects: write strategy: fail-fast: false matrix: target: [phone] -# target: [phone, tv] + # target: [phone, tv] steps: + - name: ๐Ÿ“ข Notify artifact comment workflow (started) + if: github.event_name == 'pull_request' + uses: actions/github-script@v8 + with: + script: | + await github.rest.repos.createDispatchEvent({ + owner: context.repo.owner, + repo: context.repo.repo, + event_type: 'build-started', + client_payload: { + workflow_name: 'iOS IPA Build', + target: '${{ matrix.target }}', + status: 'in_progress', + pr_number: ${{ github.event.pull_request.number }}, + commit_sha: '${{ github.event.pull_request.head.sha }}', + run_id: ${{ github.run_id }}, + run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + } + }); + - name: ๐Ÿ“ฅ Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: @@ -93,3 +114,43 @@ jobs: name: streamyfin-ios-${{ matrix.target }}-ipa-${{ env.DATE_TAG }} path: build-*.ipa retention-days: 7 + + - name: ๐Ÿ”” Notify artifact comment workflow (success) + if: success() && github.event_name == 'pull_request' + uses: actions/github-script@v8 + with: + script: | + await github.rest.repos.createDispatchEvent({ + owner: context.repo.owner, + repo: context.repo.repo, + event_type: 'build-completed', + client_payload: { + workflow_name: 'iOS IPA Build', + target: '${{ matrix.target }}', + status: 'success', + pr_number: ${{ github.event.pull_request.number }}, + commit_sha: '${{ github.event.pull_request.head.sha }}', + run_id: ${{ github.run_id }}, + run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + } + }); + + - name: ๐Ÿ”” Notify artifact comment workflow (failure) + if: failure() && github.event_name == 'pull_request' + uses: actions/github-script@v8 + with: + script: | + await github.rest.repos.createDispatchEvent({ + owner: context.repo.owner, + repo: context.repo.repo, + event_type: 'build-failed', + client_payload: { + workflow_name: 'iOS IPA Build', + target: '${{ matrix.target }}', + status: 'failure', + pr_number: ${{ github.event.pull_request.number }}, + commit_sha: '${{ github.event.pull_request.head.sha }}', + run_id: ${{ github.run_id }}, + run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + } + }); From 788f420ce58997567cb54422f78a094937f25ec6 Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 00:58:25 +0200 Subject: [PATCH 22/27] refactor: migrate artifact comment system from repository_dispatch to workflow_run Replaces the repository_dispatch event system with workflow_run triggers to improve reliability and reduce complexity. The new approach automatically detects PR associations through commit SHA lookups rather than requiring manual payload construction. Removes redundant notification steps from build workflows and simplifies the concurrency group logic. Enhances manual testing support with improved PR discovery fallbacks. --- .github/workflows/artifact-comment.yml | 142 +++++++++++-------------- .github/workflows/build-android.yml | 61 ----------- .github/workflows/build-ios.yml | 61 ----------- 3 files changed, 61 insertions(+), 203 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 0bfb99af..c4773b62 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -1,29 +1,29 @@ name: ๐Ÿ“ Artifact Comment on PR concurrency: - group: artifact-comment-${{ github.sha }} + 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] - repository_dispatch: # Triggered by build workflows when they start/complete + workflow_run: # Triggered when build workflows complete + workflows: + - "๐Ÿค– Android APK Build (Phone + TV)" + - "๐Ÿค– iOS IPA Build (Phone + TV)" types: - - build-started - - build-completed - - build-failed + - completed jobs: comment-artifacts: - if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || github.event_name == 'repository_dispatch' + 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-latest permissions: contents: read pull-requests: write actions: read - repository-projects: read steps: - name: ๐Ÿ” Get PR and Artifacts @@ -34,23 +34,23 @@ jobs: let pr; let targetCommitSha; - if (context.eventName === 'repository_dispatch') { - // Triggered by build workflows - get PR info from payload - const payload = context.payload.client_payload; - console.log('Repository dispatch payload:', JSON.stringify(payload, null, 2)); + if (context.eventName === 'workflow_run') { + // Find PR associated with this workflow run commit + console.log('Workflow run event:', context.payload.workflow_run.name); - if (!payload || !payload.pr_number) { - console.log('No PR information in repository_dispatch payload'); + 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; } - const { data: prData } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: payload.pr_number - }); - pr = prData; - targetCommitSha = payload.commit_sha || pr.head.sha; + pr = pullRequests[0]; + targetCommitSha = context.payload.workflow_run.head_sha; } else if (context.eventName === 'pull_request') { // Direct PR event @@ -58,15 +58,45 @@ jobs: targetCommitSha = pr.head.sha; } else if (context.eventName === 'workflow_dispatch') { - // Get current PR for manual testing - const prNumber = context.payload.pull_request?.number || 1101; - const { data: prData } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber - }); - pr = prData; - targetCommitSha = pr.head.sha; + // 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); @@ -157,52 +187,11 @@ jobs: } } - // Override with real-time data from repository_dispatch if available - if (context.eventName === 'repository_dispatch') { - const payload = context.payload.client_payload; - const workflowType = payload.workflow_name.includes('Android') ? 'Android' : 'iOS'; - - if (buildStatuses[workflowType]) { - // Update the existing status with real-time data - buildStatuses[workflowType].status = payload.status === 'in_progress' ? 'in_progress' : - payload.status === 'success' ? 'completed' : - payload.status === 'failure' ? 'completed' : - buildStatuses[workflowType].status; - buildStatuses[workflowType].conclusion = payload.status === 'success' ? 'success' : - payload.status === 'failure' ? 'failure' : - buildStatuses[workflowType].conclusion; - buildStatuses[workflowType].url = payload.run_url; - buildStatuses[workflowType].target = payload.target; - } else { - // Create new status entry for real-time updates - buildStatuses[workflowType] = { - name: payload.workflow_name, - status: payload.status === 'in_progress' ? 'in_progress' : - payload.status === 'success' ? 'completed' : - payload.status === 'failure' ? 'completed' : 'queued', - conclusion: payload.status === 'success' ? 'success' : - payload.status === 'failure' ? 'failure' : null, - url: payload.run_url, - runId: payload.run_id, - target: payload.target, - created_at: new Date().toISOString() - }; - } - } - console.log(`Collected ${allArtifacts.length} total artifacts from all builds`); // 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`; - - // Show event context for debugging (only for repository_dispatch) - if (context.eventName === 'repository_dispatch') { - const payload = context.payload.client_payload; - commentBody += `๐Ÿ”” **Real-time Update**: ${payload.workflow_name} (${payload.target}) - ${payload.status}\n\n`; - } - - // Progressive build status and downloads table + 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`; @@ -217,16 +206,7 @@ jobs: for (const target of buildTargets) { // Find matching workflow status - let matchingStatus = buildStatuses[target.workflowType]; - - // For repository_dispatch events, check if this specific target matches - if (context.eventName === 'repository_dispatch' && matchingStatus) { - const payload = context.payload.client_payload; - if (payload.target !== target.target) { - // This update is for a different target, show default status - matchingStatus = null; - } - } + const matchingStatus = buildStatuses[target.workflowType]; // Find matching artifact const matchingArtifact = allArtifacts.find(artifact => diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index d273f616..bc9b9ea1 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -18,7 +18,6 @@ jobs: name: ๐Ÿ—๏ธ Build Android APK permissions: contents: read - repository-projects: write strategy: fail-fast: false @@ -26,26 +25,6 @@ jobs: target: [phone, tv] steps: - - name: ๐Ÿ“ข Notify artifact comment workflow (started) - if: github.event_name == 'pull_request' - uses: actions/github-script@v8 - with: - script: | - await github.rest.repos.createDispatchEvent({ - owner: context.repo.owner, - repo: context.repo.repo, - event_type: 'build-started', - client_payload: { - workflow_name: 'Android APK Build', - target: '${{ matrix.target }}', - status: 'in_progress', - pr_number: ${{ github.event.pull_request.number }}, - commit_sha: '${{ github.event.pull_request.head.sha }}', - run_id: ${{ github.run_id }}, - run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' - } - }); - - name: ๐Ÿ“ฅ Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: @@ -112,43 +91,3 @@ jobs: path: | android/app/build/outputs/apk/release/*.apk retention-days: 7 - - - name: ๐Ÿ”” Notify artifact comment workflow (success) - if: success() && github.event_name == 'pull_request' - uses: actions/github-script@v8 - with: - script: | - await github.rest.repos.createDispatchEvent({ - owner: context.repo.owner, - repo: context.repo.repo, - event_type: 'build-completed', - client_payload: { - workflow_name: 'Android APK Build', - target: '${{ matrix.target }}', - status: 'success', - pr_number: ${{ github.event.pull_request.number }}, - commit_sha: '${{ github.event.pull_request.head.sha }}', - run_id: ${{ github.run_id }}, - run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' - } - }); - - - name: ๐Ÿ”” Notify artifact comment workflow (failure) - if: failure() && github.event_name == 'pull_request' - uses: actions/github-script@v8 - with: - script: | - await github.rest.repos.createDispatchEvent({ - owner: context.repo.owner, - repo: context.repo.repo, - event_type: 'build-failed', - client_payload: { - workflow_name: 'Android APK Build', - target: '${{ matrix.target }}', - status: 'failure', - pr_number: ${{ github.event.pull_request.number }}, - commit_sha: '${{ github.event.pull_request.head.sha }}', - run_id: ${{ github.run_id }}, - run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' - } - }); diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 27c5be6c..76e4a4d2 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -22,7 +22,6 @@ jobs: name: ๐Ÿ—๏ธ Build iOS IPA permissions: contents: read - repository-projects: write strategy: fail-fast: false @@ -31,26 +30,6 @@ jobs: # target: [phone, tv] steps: - - name: ๐Ÿ“ข Notify artifact comment workflow (started) - if: github.event_name == 'pull_request' - uses: actions/github-script@v8 - with: - script: | - await github.rest.repos.createDispatchEvent({ - owner: context.repo.owner, - repo: context.repo.repo, - event_type: 'build-started', - client_payload: { - workflow_name: 'iOS IPA Build', - target: '${{ matrix.target }}', - status: 'in_progress', - pr_number: ${{ github.event.pull_request.number }}, - commit_sha: '${{ github.event.pull_request.head.sha }}', - run_id: ${{ github.run_id }}, - run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' - } - }); - - name: ๐Ÿ“ฅ Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: @@ -114,43 +93,3 @@ jobs: name: streamyfin-ios-${{ matrix.target }}-ipa-${{ env.DATE_TAG }} path: build-*.ipa retention-days: 7 - - - name: ๐Ÿ”” Notify artifact comment workflow (success) - if: success() && github.event_name == 'pull_request' - uses: actions/github-script@v8 - with: - script: | - await github.rest.repos.createDispatchEvent({ - owner: context.repo.owner, - repo: context.repo.repo, - event_type: 'build-completed', - client_payload: { - workflow_name: 'iOS IPA Build', - target: '${{ matrix.target }}', - status: 'success', - pr_number: ${{ github.event.pull_request.number }}, - commit_sha: '${{ github.event.pull_request.head.sha }}', - run_id: ${{ github.run_id }}, - run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' - } - }); - - - name: ๐Ÿ”” Notify artifact comment workflow (failure) - if: failure() && github.event_name == 'pull_request' - uses: actions/github-script@v8 - with: - script: | - await github.rest.repos.createDispatchEvent({ - owner: context.repo.owner, - repo: context.repo.repo, - event_type: 'build-failed', - client_payload: { - workflow_name: 'iOS IPA Build', - target: '${{ matrix.target }}', - status: 'failure', - pr_number: ${{ github.event.pull_request.number }}, - commit_sha: '${{ github.event.pull_request.head.sha }}', - run_id: ${{ github.run_id }}, - run_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' - } - }); From fb0a70690eed20d82950d50097b214b9269b2d26 Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 01:20:11 +0200 Subject: [PATCH 23/27] refactor: consolidate build workflows into unified app builder Merges separate Android and iOS build workflows into a single "Build Apps" workflow to reduce duplication and simplify maintenance. Updates the artifact comment workflow to handle both the new unified workflow and legacy separate workflows for backward compatibility during transition. Removes redundant workflow files while preserving all existing functionality. --- .github/workflows/artifact-comment.yml | 54 ++--- .github/workflows/build-android.yml | 93 -------- .github/workflows/build-apps.yml | 280 +++++++++++++++++++++++++ .github/workflows/build-ios.yml | 95 --------- 4 files changed, 309 insertions(+), 213 deletions(-) delete mode 100644 .github/workflows/build-android.yml create mode 100644 .github/workflows/build-apps.yml delete mode 100644 .github/workflows/build-ios.yml diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index c4773b62..08710fe8 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -10,8 +10,7 @@ on: types: [opened, synchronize, reopened] workflow_run: # Triggered when build workflows complete workflows: - - "๐Ÿค– Android APK Build (Phone + TV)" - - "๐Ÿค– iOS IPA Build (Phone + TV)" + - "๐Ÿ—๏ธ Build Apps" types: - completed @@ -116,6 +115,7 @@ jobs: // Filter for build workflows only and sort by creation time (most recent first) const buildRuns = workflowRuns.workflow_runs .filter(run => + run.name.includes('Build Apps') || run.name.includes('Android APK Build') || run.name.includes('iOS IPA Build') ) @@ -132,57 +132,61 @@ jobs: let allArtifacts = []; let buildStatuses = {}; - // Get the most recent run for each workflow type + // Get the most recent run for the unified apps workflow + const latestAppsRun = buildRuns.find(run => run.name.includes('Build Apps')); const latestAndroidRun = buildRuns.find(run => run.name.includes('Android APK Build')); const latestIOSRun = buildRuns.find(run => run.name.includes('iOS IPA Build')); - // Store status for each workflow type - if (latestAndroidRun) { + // Store status for unified workflow (preferred) or fallback to separate workflows + const appsWorkflowRun = latestAppsRun || latestAndroidRun; + if (appsWorkflowRun) { buildStatuses['Android'] = { - name: latestAndroidRun.name, - status: latestAndroidRun.status, - conclusion: latestAndroidRun.conclusion, - url: latestAndroidRun.html_url, - runId: latestAndroidRun.id, - created_at: latestAndroidRun.created_at + name: appsWorkflowRun.name, + status: appsWorkflowRun.status, + conclusion: appsWorkflowRun.conclusion, + url: appsWorkflowRun.html_url, + runId: appsWorkflowRun.id, + created_at: appsWorkflowRun.created_at }; // Collect artifacts if completed successfully - if (latestAndroidRun.conclusion === 'success') { + if (appsWorkflowRun.conclusion === 'success') { try { const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ owner: context.repo.owner, repo: context.repo.repo, - run_id: latestAndroidRun.id + run_id: appsWorkflowRun.id }); allArtifacts.push(...artifacts.artifacts); } catch (error) { - console.log(`Failed to get Android artifacts for run ${latestAndroidRun.id}:`, error.message); + console.log(`Failed to get apps artifacts for run ${appsWorkflowRun.id}:`, error.message); } } } - if (latestIOSRun) { + // For iOS, use the same workflow run (since it's all in one now) or fallback to separate + const iosWorkflowRun = latestAppsRun || latestIOSRun; + if (iosWorkflowRun) { buildStatuses['iOS'] = { - name: latestIOSRun.name, - status: latestIOSRun.status, - conclusion: latestIOSRun.conclusion, - url: latestIOSRun.html_url, - runId: latestIOSRun.id, - created_at: latestIOSRun.created_at + name: iosWorkflowRun.name, + status: iosWorkflowRun.status, + conclusion: iosWorkflowRun.conclusion, + url: iosWorkflowRun.html_url, + runId: iosWorkflowRun.id, + created_at: iosWorkflowRun.created_at }; - // Collect artifacts if completed successfully - if (latestIOSRun.conclusion === 'success') { + // Only collect artifacts if not already collected from apps workflow + if (!latestAppsRun && iosWorkflowRun.conclusion === 'success') { try { const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ owner: context.repo.owner, repo: context.repo.repo, - run_id: latestIOSRun.id + run_id: iosWorkflowRun.id }); allArtifacts.push(...artifacts.artifacts); } catch (error) { - console.log(`Failed to get iOS artifacts for run ${latestIOSRun.id}:`, error.message); + console.log(`Failed to get iOS artifacts for run ${iosWorkflowRun.id}:`, error.message); } } } diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml deleted file mode 100644 index bc9b9ea1..00000000 --- a/.github/workflows/build-android.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: ๐Ÿค– Android APK Build (Phone + TV) - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -on: - workflow_dispatch: - pull_request: - branches: [develop, master] - push: - branches: [develop, master] - -jobs: - build-android: - if: (!contains(github.event.head_commit.message, '[skip ci]')) - runs-on: ubuntu-24.04 - name: ๐Ÿ—๏ธ Build Android APK - permissions: - contents: read - - strategy: - fail-fast: false - matrix: - target: [phone, tv] - - steps: - - name: ๐Ÿ“ฅ Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - fetch-depth: 0 - submodules: recursive - show-progress: false - - - name: ๐Ÿž Setup Bun - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 - with: - bun-version: latest - - - name: ๐Ÿ’พ Cache Bun dependencies - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-bun-develop - ${{ runner.os }}-bun-develop - - - name: ๐Ÿ“ฆ Install dependencies and reload submodules - run: | - bun install --frozen-lockfile - bun run submodule-reload - - - name: ๐Ÿ’พ Cache Gradle global - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} - restore-keys: ${{ runner.os }}-gradle-develop - - - name: ๐Ÿ› ๏ธ Generate project files - run: | - if [ "${{ matrix.target }}" = "tv" ]; then - bun run prebuild:tv - else - bun run prebuild - fi - - - name: ๐Ÿ’พ Cache project Gradle (.gradle) - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: android/.gradle - key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} - restore-keys: ${{ runner.os }}-android-gradle-develop - - - name: ๐Ÿš€ Build APK - env: - EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }} - run: bun run build:android:local - - - name: ๐Ÿ“… Set date tag - run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV - - - name: ๐Ÿ“ค Upload APK artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: streamyfin-android-${{ matrix.target }}-apk-${{ env.DATE_TAG }} - path: | - android/app/build/outputs/apk/release/*.apk - retention-days: 7 diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml new file mode 100644 index 00000000..ad770414 --- /dev/null +++ b/.github/workflows/build-apps.yml @@ -0,0 +1,280 @@ +name: ๐Ÿ—๏ธ Build Apps + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + workflow_dispatch: + pull_request: + branches: [develop, master] + push: + branches: [develop, master] + +jobs: + build-android-phone: + if: (!contains(github.event.head_commit.message, '[skip ci]')) + runs-on: ubuntu-24.04 + name: ๐Ÿค– Build Android APK (Phone) + permissions: + contents: read + + steps: + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: recursive + show-progress: false + + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + with: + bun-version: latest + + - name: ๐Ÿ’พ Cache Bun dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun-develop + ${{ runner.os }}-bun-develop + + - name: ๐Ÿ“ฆ Install dependencies and reload submodules + run: | + bun install --frozen-lockfile + bun run submodule-reload + + - name: ๐Ÿ’พ Cache Gradle global + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: ๐Ÿ› ๏ธ Generate project files + run: bun run prebuild + + - name: ๐Ÿ’พ Cache project Gradle (.gradle) + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: android/.gradle + key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: ${{ runner.os }}-android-gradle-develop + + - name: ๐Ÿš€ Build APK + env: + EXPO_TV: 0 + run: bun run build:android:local + + - name: ๐Ÿ“… Set date tag + run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV + + - name: ๐Ÿ“ค Upload APK artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: streamyfin-android-phone-apk-${{ env.DATE_TAG }} + path: | + android/app/build/outputs/apk/release/*.apk + retention-days: 7 + + build-android-tv: + if: (!contains(github.event.head_commit.message, '[skip ci]')) + runs-on: ubuntu-24.04 + name: ๐Ÿค– Build Android APK (TV) + permissions: + contents: read + + steps: + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: recursive + show-progress: false + + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + with: + bun-version: latest + + - name: ๐Ÿ’พ Cache Bun dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun-develop + ${{ runner.os }}-bun-develop + + - name: ๐Ÿ“ฆ Install dependencies and reload submodules + run: | + bun install --frozen-lockfile + bun run submodule-reload + + - name: ๐Ÿ’พ Cache Gradle global + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: ๐Ÿ› ๏ธ Generate project files + run: bun run prebuild:tv + + - name: ๐Ÿ’พ Cache project Gradle (.gradle) + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: android/.gradle + key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: ${{ runner.os }}-android-gradle-develop + + - name: ๐Ÿš€ Build APK + env: + EXPO_TV: 1 + run: bun run build:android:local + + - name: ๐Ÿ“… Set date tag + run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV + + - name: ๐Ÿ“ค Upload APK artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: streamyfin-android-tv-apk-${{ env.DATE_TAG }} + path: | + android/app/build/outputs/apk/release/*.apk + retention-days: 7 + + 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-15 + name: ๐ŸŽ Build iOS IPA (Phone) + permissions: + contents: read + + steps: + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: recursive + show-progress: false + + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + with: + bun-version: latest + + - name: ๐Ÿ’พ Cache Bun dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-cache + + - name: ๐Ÿ“ฆ Install dependencies and reload submodules + run: | + bun install --frozen-lockfile + bun run submodule-reload + + - name: ๐Ÿ› ๏ธ Generate project files + run: bun run prebuild + + - name: ๐Ÿ—๏ธ Setup EAS + uses: expo/expo-github-action@main + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + eas-cache: true + + - name: โš™๏ธ Ensure iOS SDKs installed + run: xcodebuild -downloadPlatform iOS + + - name: ๐Ÿš€ Build iOS app + env: + EXPO_TV: 0 + run: eas build -p ios --local --non-interactive + + - name: ๐Ÿ“… Set date tag + run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV + + - name: ๐Ÿ“ค Upload IPA artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }} + path: build-*.ipa + retention-days: 7 + + # Disabled for now - uncomment when ready to build iOS TV + # build-ios-tv: + # 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-15 + # name: ๐ŸŽ Build iOS IPA (TV) + # permissions: + # contents: read + # + # steps: + # - name: ๐Ÿ“ฅ Checkout code + # uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + # with: + # ref: ${{ github.event.pull_request.head.sha || github.sha }} + # fetch-depth: 0 + # submodules: recursive + # show-progress: false + # + # - name: ๐Ÿž Setup Bun + # uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + # with: + # bun-version: latest + # + # - name: ๐Ÿ’พ Cache Bun dependencies + # uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + # with: + # path: ~/.bun/install/cache + # key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} + # restore-keys: | + # ${{ runner.os }}-bun-cache + # + # - name: ๐Ÿ“ฆ Install dependencies and reload submodules + # run: | + # bun install --frozen-lockfile + # bun run submodule-reload + # + # - name: ๐Ÿ› ๏ธ Generate project files + # run: bun run prebuild:tv + # + # - name: ๐Ÿ—๏ธ Setup EAS + # uses: expo/expo-github-action@main + # with: + # eas-version: latest + # token: ${{ secrets.EXPO_TOKEN }} + # eas-cache: true + # + # - name: โš™๏ธ Ensure tvOS SDKs installed + # run: xcodebuild -downloadPlatform tvOS + # + # - name: ๐Ÿš€ Build iOS app + # env: + # EXPO_TV: 1 + # run: eas build -p ios --local --non-interactive + # + # - name: ๐Ÿ“… Set date tag + # run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV + # + # - name: ๐Ÿ“ค Upload IPA artifact + # uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + # with: + # name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }} + # path: build-*.ipa + # retention-days: 7 diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml deleted file mode 100644 index 76e4a4d2..00000000 --- a/.github/workflows/build-ios.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: ๐Ÿค– iOS IPA Build (Phone + TV) - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -on: - workflow_dispatch: - pull_request: - branches: [develop, master] - paths-ignore: - - "*.md" - push: - branches: [develop, master] - paths-ignore: - - "*.md" - -jobs: - build-ios: - 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-15 - name: ๐Ÿ—๏ธ Build iOS IPA - permissions: - contents: read - - strategy: - fail-fast: false - matrix: - target: [phone] - # target: [phone, tv] - - steps: - - name: ๐Ÿ“ฅ Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - fetch-depth: 0 - submodules: recursive - show-progress: false - - - name: ๐Ÿž Setup Bun - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 - with: - bun-version: latest - - - name: ๐Ÿ’พ Cache Bun dependencies - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun-cache - - - name: ๐Ÿ“ฆ Install dependencies and reload submodules - run: | - bun install --frozen-lockfile - bun run submodule-reload - - - name: ๐Ÿ› ๏ธ Generate project files - run: | - if [ "${{ matrix.target }}" = "tv" ]; then - bun run prebuild:tv - else - bun run prebuild - fi - - - name: ๐Ÿ—๏ธ Setup EAS - uses: expo/expo-github-action@main - with: - eas-version: latest - token: ${{ secrets.EXPO_TOKEN }} - eas-cache: true - - - name: โš™๏ธ Ensure iOS/tvOS SDKs installed - run: | - if [ "${{ matrix.target }}" = "tv" ]; then - xcodebuild -downloadPlatform tvOS - else - xcodebuild -downloadPlatform iOS - fi - - - name: ๐Ÿš€ Build iOS app - env: - EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }} - run: eas build -p ios --local --non-interactive - - - name: ๐Ÿ“… Set date tag - run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV - - - name: ๐Ÿ“ค Upload IPA artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: streamyfin-ios-${{ matrix.target }}-ipa-${{ env.DATE_TAG }} - path: build-*.ipa - retention-days: 7 From 312a59c5b0a1369b7aa27374284d2cdf9a3fa0a9 Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 01:42:17 +0200 Subject: [PATCH 24/27] refactor(ci): improve build workflow handling and artifact collection logic Restructures the GitHub Actions workflow to better handle consolidated vs separate build workflows. Changes the artifact collection to trigger on workflow completion rather than just success, improving visibility of failed builds. Adds explicit fallback logic for backward compatibility with separate Android and iOS workflows. Introduces artifact pattern matching for more reliable build target identification and adds special handling to disable iOS TV builds. Enhances debugging output to show which workflow type is being used and lists all discovered artifacts. --- .github/workflows/artifact-comment.yml | 133 +++++++++++++++++-------- 1 file changed, 90 insertions(+), 43 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 08710fe8..0e9eea6c 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -137,62 +137,105 @@ jobs: const latestAndroidRun = buildRuns.find(run => run.name.includes('Android APK Build')); const latestIOSRun = buildRuns.find(run => run.name.includes('iOS IPA Build')); - // Store status for unified workflow (preferred) or fallback to separate workflows - const appsWorkflowRun = latestAppsRun || latestAndroidRun; - if (appsWorkflowRun) { + // For the consolidated workflow, both Android and iOS share the same run + if (latestAppsRun) { + // Both platforms use same workflow run status buildStatuses['Android'] = { - name: appsWorkflowRun.name, - status: appsWorkflowRun.status, - conclusion: appsWorkflowRun.conclusion, - url: appsWorkflowRun.html_url, - runId: appsWorkflowRun.id, - created_at: appsWorkflowRun.created_at + name: latestAppsRun.name, + status: latestAppsRun.status, + conclusion: latestAppsRun.conclusion, + url: latestAppsRun.html_url, + runId: latestAppsRun.id, + created_at: latestAppsRun.created_at }; - // Collect artifacts if completed successfully - if (appsWorkflowRun.conclusion === 'success') { + buildStatuses['iOS'] = { + name: latestAppsRun.name, + status: latestAppsRun.status, + conclusion: latestAppsRun.conclusion, + url: latestAppsRun.html_url, + runId: latestAppsRun.id, + created_at: latestAppsRun.created_at + }; + + // Collect artifacts if workflow has completed (regardless of success/failure) + if (latestAppsRun.status === 'completed') { try { const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ owner: context.repo.owner, repo: context.repo.repo, - run_id: appsWorkflowRun.id + run_id: latestAppsRun.id }); allArtifacts.push(...artifacts.artifacts); } catch (error) { - console.log(`Failed to get apps artifacts for run ${appsWorkflowRun.id}:`, error.message); + 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); + } } } - } - - // For iOS, use the same workflow run (since it's all in one now) or fallback to separate - const iosWorkflowRun = latestAppsRun || latestIOSRun; - if (iosWorkflowRun) { - buildStatuses['iOS'] = { - name: iosWorkflowRun.name, - status: iosWorkflowRun.status, - conclusion: iosWorkflowRun.conclusion, - url: iosWorkflowRun.html_url, - runId: iosWorkflowRun.id, - created_at: iosWorkflowRun.created_at - }; - // Only collect artifacts if not already collected from apps workflow - if (!latestAppsRun && iosWorkflowRun.conclusion === 'success') { - try { - const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: iosWorkflowRun.id - }); - allArtifacts.push(...artifacts.artifacts); - } catch (error) { - console.log(`Failed to get iOS artifacts for run ${iosWorkflowRun.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})`); + }); + // 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 @@ -202,10 +245,10 @@ jobs: // Process each expected build target individually const buildTargets = [ - { name: 'Android Phone', platform: '๐Ÿค–', device: '๐Ÿ“ฑ', workflowType: 'Android', target: 'phone' }, - { name: 'Android TV', platform: '๐Ÿค–', device: '๐Ÿ“บ', workflowType: 'Android', target: 'tv' }, - { name: 'iOS Phone', platform: '๐ŸŽ', device: '๐Ÿ“ฑ', workflowType: 'iOS', target: 'phone' }, - { name: 'iOS TV', platform: '๐ŸŽ', device: '๐Ÿ“บ', workflowType: 'iOS', target: 'tv' } + { name: 'Android Phone', platform: '๐Ÿค–', device: '๐Ÿ“ฑ', workflowType: 'Android', target: 'phone', artifactPattern: /android.*phone/i }, + { name: 'Android TV', platform: '๐Ÿค–', device: '๐Ÿ“บ', workflowType: 'Android', target: 'tv', artifactPattern: /android.*tv/i }, + { name: 'iOS Phone', platform: '๐ŸŽ', device: '๐Ÿ“ฑ', workflowType: 'iOS', target: 'phone', artifactPattern: /ios.*phone/i }, + { name: 'iOS TV', platform: '๐ŸŽ', device: '๐Ÿ“บ', workflowType: 'iOS', target: 'tv', artifactPattern: /ios.*tv/i } ]; for (const target of buildTargets) { @@ -214,13 +257,17 @@ jobs: // Find matching artifact const matchingArtifact = allArtifacts.find(artifact => - target.pattern.test(artifact.name) + target.artifactPattern.test(artifact.name) ); let status = 'โณ Pending'; let downloadLink = '*Waiting for build...*'; - if (matchingStatus) { + // Special case for iOS TV - show as disabled + if (target.name === 'iOS TV') { + status = '๐Ÿ’ค Disabled'; + downloadLink = '*Disabled for now*'; + } else if (matchingStatus) { if (matchingStatus.conclusion === 'success' && matchingArtifact) { status = 'โœ… Complete'; const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/${matchingArtifact.name}.zip`; From 0a41962ddfd0f9c4f2553ba4b906febe28bbf90f Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 01:49:38 +0200 Subject: [PATCH 25/27] fix: improve build status handling and artifact links Replaces nightly.link with direct GitHub artifact URLs for better reliability. Adds handling for cancelled builds and edge cases where workflows complete but artifacts aren't immediately available or conclusions are pending. Improves status messages to provide more detailed information for unexpected build states. --- .github/workflows/artifact-comment.yml | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 0e9eea6c..61a94b0a 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -270,22 +270,33 @@ jobs: } else if (matchingStatus) { if (matchingStatus.conclusion === 'success' && matchingArtifact) { status = 'โœ… Complete'; - const nightlyLink = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/${matchingArtifact.name}.zip`; + 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'; - downloadLink = `[๐Ÿ“ฅ Download ${fileType}](${nightlyLink})`; + downloadLink = `[๐Ÿ“ฅ Download ${fileType}](${directLink})`; } 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})`; downloadLink = '*Build in progress...*'; } else if (matchingStatus.status === 'queued') { status = `โณ [Queued](${matchingStatus.url})`; downloadLink = '*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 { - // Show any other status with timestamp for debugging - status = `๐Ÿ”„ [${matchingStatus.status}](${matchingStatus.url})`; - downloadLink = `*Status: ${matchingStatus.status}*`; + // Fallback for any unexpected states + status = `โ“ [${matchingStatus.status}/${matchingStatus.conclusion || 'pending'}](${matchingStatus.url})`; + downloadLink = `*Status: ${matchingStatus.status}, Conclusion: ${matchingStatus.conclusion || 'pending'}*`; } } From 71a3c5e92bfbea1e84cf8c901544a0a125b3995f Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 01:57:18 +0200 Subject: [PATCH 26/27] feat: improve GitHub workflow status tracking Enhances artifact comment workflow to provide more accurate build status reporting by tracking individual job statuses within consolidated workflows rather than using workflow-level status. Excludes cancelled workflow runs from consideration and prioritizes active runs over completed ones when determining build status. Maps specific job names to build targets (Android Phone, Android TV, iOS Phone) to provide granular status information for each platform and device combination. Improves artifact collection logic to gather artifacts when any job completes successfully, not just when entire workflow completes. --- .github/workflows/artifact-comment.yml | 129 ++++++++++++++++++------- 1 file changed, 94 insertions(+), 35 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 61a94b0a..0a8cc576 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -112,54 +112,113 @@ jobs: per_page: 30 }); - // Filter for build workflows only and sort by creation time (most recent first) + // Filter for build workflows only, exclude cancelled runs, and sort by creation time (most recent first) const buildRuns = workflowRuns.workflow_runs .filter(run => - run.name.includes('Build Apps') || + (run.name.includes('Build Apps') || run.name.includes('Android APK Build') || - run.name.includes('iOS IPA Build') + run.name.includes('iOS IPA Build')) && + run.conclusion !== 'cancelled' // Ignore cancelled runs ) .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); - console.log(`Found ${buildRuns.length} build workflow runs for this commit`); + console.log(`Found ${buildRuns.length} non-cancelled 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 - get most recent run for each workflow type + // Collect artifacts and statuses from builds - prioritize active runs over completed ones let allArtifacts = []; let buildStatuses = {}; - // Get the most recent run for the unified apps workflow - const latestAppsRun = buildRuns.find(run => run.name.includes('Build Apps')); - const latestAndroidRun = buildRuns.find(run => run.name.includes('Android APK Build')); - const latestIOSRun = buildRuns.find(run => run.name.includes('iOS IPA Build')); + // Get the most relevant run for each workflow type (prioritize in_progress over completed) + 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; + // Finally fall back to most recent completed run + return matchingRuns[0]; // Already sorted by most recent first + }; - // For the consolidated workflow, both Android and iOS share the same run + const latestAppsRun = findBestRun('Build Apps'); + const latestAndroidRun = findBestRun('Android APK Build'); + const latestIOSRun = findBestRun('iOS IPA Build'); + + // For the consolidated workflow, get individual job statuses if (latestAppsRun) { - // Both platforms use same workflow run status - buildStatuses['Android'] = { - name: latestAppsRun.name, - status: latestAppsRun.status, - conclusion: latestAppsRun.conclusion, - url: latestAppsRun.html_url, - runId: latestAppsRun.id, - created_at: latestAppsRun.created_at - }; + console.log(`Getting individual job statuses for run ${latestAppsRun.id}`); - buildStatuses['iOS'] = { - name: latestAppsRun.name, - status: latestAppsRun.status, - conclusion: latestAppsRun.conclusion, - url: latestAppsRun.html_url, - runId: latestAppsRun.id, - created_at: latestAppsRun.created_at - }; + 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'}`); + }); + + // Map job names to our build targets + const jobMappings = { + 'Android Phone': ['๐Ÿค– Build Android APK (Phone)', 'build-android-phone'], + 'Android TV': ['๐Ÿค– Build Android APK (TV)', 'build-android-tv'], + 'iOS Phone': ['๐ŸŽ Build iOS IPA (Phone)', 'build-ios-phone'] + }; + + // 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) + ); + + 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 + }; + 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 + }; + } + } + + } catch (error) { + console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message); + // Fallback to workflow-level status + buildStatuses['Android Phone'] = buildStatuses['Android TV'] = buildStatuses['iOS Phone'] = { + name: latestAppsRun.name, + status: latestAppsRun.status, + conclusion: latestAppsRun.conclusion, + url: latestAppsRun.html_url, + runId: latestAppsRun.id, + created_at: latestAppsRun.created_at + }; + } - // Collect artifacts if workflow has completed (regardless of success/failure) - if (latestAppsRun.status === 'completed') { + // 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, @@ -245,15 +304,15 @@ jobs: // Process each expected build target individually const buildTargets = [ - { name: 'Android Phone', platform: '๐Ÿค–', device: '๐Ÿ“ฑ', workflowType: 'Android', target: 'phone', artifactPattern: /android.*phone/i }, - { name: 'Android TV', platform: '๐Ÿค–', device: '๐Ÿ“บ', workflowType: 'Android', target: 'tv', artifactPattern: /android.*tv/i }, - { name: 'iOS Phone', platform: '๐ŸŽ', device: '๐Ÿ“ฑ', workflowType: 'iOS', target: 'phone', artifactPattern: /ios.*phone/i }, - { name: 'iOS TV', platform: '๐ŸŽ', device: '๐Ÿ“บ', workflowType: 'iOS', target: 'tv', artifactPattern: /ios.*tv/i } + { name: 'Android Phone', platform: '๐Ÿค–', device: '๐Ÿ“ฑ', statusKey: 'Android Phone', artifactPattern: /android.*phone/i }, + { name: 'Android TV', platform: '๐Ÿค–', device: '๐Ÿ“บ', statusKey: 'Android TV', artifactPattern: /android.*tv/i }, + { name: 'iOS Phone', platform: '๐ŸŽ', device: '๐Ÿ“ฑ', statusKey: 'iOS Phone', artifactPattern: /ios.*phone/i }, + { name: 'iOS TV', platform: '๐ŸŽ', device: '๐Ÿ“บ', statusKey: 'iOS TV', artifactPattern: /ios.*tv/i } ]; for (const target of buildTargets) { - // Find matching workflow status - const matchingStatus = buildStatuses[target.workflowType]; + // Find matching job status directly + const matchingStatus = buildStatuses[target.statusKey]; // Find matching artifact const matchingArtifact = allArtifacts.find(artifact => From c6ad06b0843fb90d7e06f3c443aee0056888fbc3 Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 30 Sep 2025 02:15:25 +0200 Subject: [PATCH 27/27] fix: improve workflow run detection for cancelled jobs Enhances the artifact comment workflow to better handle cancelled workflow runs by checking individual job statuses instead of dismissing entire workflows. Previously, workflows marked as cancelled at the top level were completely ignored, even if some jobs within them were still running or completed successfully. Now prioritizes active jobs over cancelled ones and validates that at least one job is actually running before processing a workflow run, preventing false negatives while still filtering out truly cancelled workflows. --- .github/workflows/artifact-comment.yml | 38 ++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml index 0a8cc576..d032dd67 100644 --- a/.github/workflows/artifact-comment.yml +++ b/.github/workflows/artifact-comment.yml @@ -112,13 +112,12 @@ jobs: per_page: 30 }); - // Filter for build workflows only, exclude cancelled runs, and sort by creation time (most recent first) + // 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')) && - run.conclusion !== 'cancelled' // Ignore cancelled runs + run.name.includes('iOS IPA Build')) ) .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); @@ -133,16 +132,28 @@ jobs: let allArtifacts = []; let buildStatuses = {}; - // Get the most relevant run for each workflow type (prioritize in_progress over completed) + // 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; - // Finally fall back to most recent completed run + + // 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 }; @@ -152,7 +163,7 @@ jobs: // For the consolidated workflow, get individual job statuses if (latestAppsRun) { - console.log(`Getting individual job statuses for run ${latestAppsRun.id}`); + 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 @@ -167,6 +178,21 @@ jobs: 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 + } + // Map job names to our build targets const jobMappings = { 'Android Phone': ['๐Ÿค– Build Android APK (Phone)', 'build-android-phone'],