name: Build and Make Electron App on: push: branches: - master tags: - 'v*.*.*' pull_request: branches: - master workflow_dispatch: jobs: build: name: Build on ${{ matrix.os }} ${{ matrix.arch }} runs-on: ${{ matrix.runner }} timeout-minutes: 60 strategy: matrix: include: # macOS builds - separate runners to avoid native module conflicts - os: macos runner: macos-15-intel arch: x64 - os: macos runner: macos-latest arch: arm64 # Linux and Windows - os: linux runner: ubuntu-22.04 linux_profile: standard - os: linux runner: ubuntu-24.04 linux_profile: flatpak - os: windows runner: windows-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '22' cache: 'pnpm' - name: Install Linux system dependencies if: matrix.os == 'linux' run: | sudo apt-get update sudo apt-get install --no-install-recommends -y rpm libarchive-tools flatpak flatpak-builder appstream # Configure Flatpak # 1. Add the Flathub repository (source of runtimes) flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo # 2. Install the standard Freedesktop Platform and SDK (required by electron-builder) # We install version 24.08 as a safe default, electron-builder might pick what it needs flatpak install --user -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 - name: Select Linux packaging targets for CI profile if: matrix.os == 'linux' run: | node -e " const fs = require('fs'); const path = 'electron-builder.json'; const config = JSON.parse(fs.readFileSync(path, 'utf8')); const targets = Array.isArray(config.linux?.target) ? config.linux.target : []; const profile = '${{ matrix.linux_profile }}'; if (profile === 'standard') { config.linux.target = targets.filter((entry) => String(entry.target).toLowerCase() !== 'flatpak'); } else if (profile === 'flatpak') { config.linux.target = targets.filter((entry) => String(entry.target).toLowerCase() === 'flatpak'); } fs.writeFileSync(path, JSON.stringify(config, null, 4) + '\n'); " - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build frontend run: pnpm nx build web --skip-nx-cache - name: Build backend run: pnpm run build:backend - name: Validate AppStream metadata if: matrix.os == 'linux' run: appstreamcli validate --pedantic --no-net apps/electron-backend/linux/com.fourgray.iptvnator.metainfo.xml - name: Build website if: matrix.os == 'linux' run: pnpm nx build website --skip-nx-cache - name: Verify AppStream website assets if: matrix.os == 'linux' shell: bash run: | set -euo pipefail for screenshot in player playlists epg settings; do if ! find dist/apps/website -path "*/appstream/${screenshot}.png" -print -quit | grep -q .; then echo "::error::Missing AppStream website asset for ${screenshot}.png" exit 1 fi done - name: Override macOS arch in electron-builder.json if: matrix.os == 'macos' run: | # Replace the mac arch array with just the target architecture node -e " const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('electron-builder.json', 'utf8')); pkg.mac.target.arch = ['${{ matrix.arch }}']; fs.writeFileSync('electron-builder.json', JSON.stringify(pkg, null, 4)); " - name: Validate macOS signing configuration if: matrix.os == 'macos' && github.event_name != 'pull_request' shell: bash env: CSC_NAME: ${{ vars.CSC_NAME }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | if [ -z "${CSC_NAME}" ]; then echo "::error::Missing CSC_NAME repository variable for deterministic macOS code signing." exit 1 fi if [ -z "${CSC_LINK}" ] || [ -z "${CSC_KEY_PASSWORD}" ]; then echo "::error::Missing CSC_LINK or CSC_KEY_PASSWORD secret for macOS code signing." exit 1 fi has_api_key_credentials=false if [ -n "${APPLE_API_KEY_CONTENT}" ] || [ -n "${APPLE_API_KEY_ID}" ] || [ -n "${APPLE_API_ISSUER}" ]; then if [ -z "${APPLE_API_KEY_CONTENT}" ] || [ -z "${APPLE_API_KEY_ID}" ] || [ -z "${APPLE_API_ISSUER}" ]; then echo "::error::APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER must all be set for App Store Connect API key notarization." exit 1 fi has_api_key_credentials=true fi has_apple_id_credentials=false if [ -n "${APPLE_ID}" ] || [ -n "${APPLE_APP_SPECIFIC_PASSWORD}" ] || [ -n "${APPLE_TEAM_ID}" ]; then if [ -z "${APPLE_ID}" ] || [ -z "${APPLE_APP_SPECIFIC_PASSWORD}" ] || [ -z "${APPLE_TEAM_ID}" ]; then echo "::error::APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID must all be set for Apple ID notarization." exit 1 fi has_apple_id_credentials=true fi if [ "${has_api_key_credentials}" = false ] && [ "${has_apple_id_credentials}" = false ]; then echo "::error::Missing notarization credentials. Configure either APPLE_API_KEY + APPLE_API_KEY_ID + APPLE_API_ISSUER, or APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID." exit 1 fi - name: Prepare macOS notarization credentials if: matrix.os == 'macos' && github.event_name != 'pull_request' shell: bash env: APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | if [ -n "${APPLE_API_KEY_CONTENT}" ]; then APPLE_API_KEY_PATH="${RUNNER_TEMP}/AuthKey_${APPLE_API_KEY_ID}.p8" printf '%s' "${APPLE_API_KEY_CONTENT}" > "${APPLE_API_KEY_PATH}" chmod 600 "${APPLE_API_KEY_PATH}" echo "APPLE_API_KEY=${APPLE_API_KEY_PATH}" >> "${GITHUB_ENV}" echo "APPLE_API_KEY_ID=${APPLE_API_KEY_ID}" >> "${GITHUB_ENV}" echo "APPLE_API_ISSUER=${APPLE_API_ISSUER}" >> "${GITHUB_ENV}" echo "APPLE_ID=" >> "${GITHUB_ENV}" echo "APPLE_APP_SPECIFIC_PASSWORD=" >> "${GITHUB_ENV}" echo "APPLE_TEAM_ID=" >> "${GITHUB_ENV}" exit 0 fi if [ -n "${APPLE_ID}" ]; then echo "APPLE_API_KEY=" >> "${GITHUB_ENV}" echo "APPLE_API_KEY_ID=" >> "${GITHUB_ENV}" echo "APPLE_API_ISSUER=" >> "${GITHUB_ENV}" echo "APPLE_ID=${APPLE_ID}" >> "${GITHUB_ENV}" echo "APPLE_APP_SPECIFIC_PASSWORD=${APPLE_APP_SPECIFIC_PASSWORD}" >> "${GITHUB_ENV}" echo "APPLE_TEAM_ID=${APPLE_TEAM_ID}" >> "${GITHUB_ENV}" fi - name: Make Electron app (macOS) if: matrix.os == 'macos' && github.event_name != 'pull_request' env: CSC_NAME: ${{ vars.CSC_NAME }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} DEBUG: electron-builder,electron-notarize* run: pnpm run make:app - name: Verify signed macOS app if: matrix.os == 'macos' && github.event_name != 'pull_request' shell: bash run: | set -euo pipefail case "${{ matrix.arch }}" in x64) APP_PATH="dist/executables/mac/IPTVnator.app" ;; arm64) APP_PATH="dist/executables/mac-arm64/IPTVnator.app" ;; *) echo "::error::Unsupported macOS arch: ${{ matrix.arch }}" exit 1 ;; esac print_debug_attrs() { echo "::group::Extended attributes" xattr -lr "${APP_PATH}" | sed -n '1,120p' || true echo "::endgroup::" } trap print_debug_attrs ERR if [ ! -d "${APP_PATH}" ]; then echo "::error::Expected app bundle not found at ${APP_PATH}" exit 1 fi SIGNATURE_INFO="$(codesign -dv --verbose=4 "${APP_PATH}" 2>&1)" printf '%s\n' "${SIGNATURE_INFO}" if printf '%s\n' "${SIGNATURE_INFO}" | grep -q 'Signature=adhoc'; then echo "::error::macOS app is still ad-hoc signed." exit 1 fi if printf '%s\n' "${SIGNATURE_INFO}" | grep -q 'TeamIdentifier=not set'; then echo "::error::macOS app is missing a TeamIdentifier." exit 1 fi codesign --verify --deep --strict --verbose=4 "${APP_PATH}" spctl -a -vvv --type execute "${APP_PATH}" xcrun stapler validate "${APP_PATH}" - name: Make Electron app if: matrix.os != 'macos' || github.event_name == 'pull_request' run: pnpm run make:app - name: Verify packaged worker layout shell: bash env: PACKAGE_OS: ${{ matrix.os }} PACKAGE_ARCH: ${{ matrix.arch || '' }} run: pnpm run verify:package-layout -- "$PACKAGE_OS" "$PACKAGE_ARCH" - name: Smoke test packaged Flatpak launcher if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak' shell: bash run: | set -euo pipefail FLATPAK_BUNDLE="$(find dist/executables -maxdepth 1 -name '*.flatpak' | head -n 1)" if [ -z "${FLATPAK_BUNDLE}" ]; then echo "::error::Flatpak bundle not found in dist/executables" exit 1 fi flatpak install --user --noninteractive -y "${FLATPAK_BUNDLE}" flatpak run --command=sh com.fourgray.iptvnator -c ' set -euo pipefail test -f /app/share/metainfo/com.fourgray.iptvnator.metainfo.xml test -L /app/bin/iptvnator LAUNCHER_PATH="$(readlink -f /app/bin/iptvnator)" test -f "${LAUNCHER_PATH}" test -f "${LAUNCHER_PATH}.bin" grep -q '\''readlink -f "$SCRIPT_PATH"'\'' "${LAUNCHER_PATH}" grep -q '\''exec "$SCRIPT_DIR/iptvnator.bin"'\'' "${LAUNCHER_PATH}" ' - name: Upload artifacts (macOS) if: matrix.os == 'macos' uses: actions/upload-artifact@v4 with: name: macos-${{ matrix.arch }}-artifacts path: | dist/executables/**/*.dmg dist/executables/**/*.zip retention-days: 7 - name: Upload artifacts (Linux) if: matrix.os == 'linux' && matrix.linux_profile == 'standard' uses: actions/upload-artifact@v4 with: name: linux-artifacts path: | dist/executables/**/*.deb dist/executables/**/*.rpm dist/executables/**/*.snap dist/executables/**/*.AppImage dist/executables/**/*.tar.gz dist/executables/**/*.pacman retention-days: 7 - name: Upload artifacts (Flatpak) if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak' uses: actions/upload-artifact@v4 with: name: linux-flatpak-artifacts path: | dist/executables/**/*.flatpak retention-days: 7 - name: Upload artifacts (Windows) if: matrix.os == 'windows' uses: actions/upload-artifact@v4 with: name: windows-artifacts path: | dist/executables/**/*.exe dist/executables/**/*.msi dist/executables/**/*.zip retention-days: 7 create-release: name: Create Draft Release needs: build runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout code uses: actions/checkout@v4 - name: Download all artifacts uses: actions/download-artifact@v4 with: path: artifacts - name: Display structure of downloaded files run: ls -R artifacts - name: Get version from package.json id: package-version run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT - name: Create Draft Release uses: softprops/action-gh-release@v2 with: draft: true prerelease: ${{ github.event_name == 'pull_request' }} name: Release v${{ steps.package-version.outputs.version }} tag_name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('test-{0}', github.sha) }} generate_release_notes: true files: | artifacts/macos-x64-artifacts/*-x64.dmg artifacts/macos-x64-artifacts/*-x64.zip artifacts/macos-arm64-artifacts/*-arm64.dmg artifacts/macos-arm64-artifacts/*-arm64.zip artifacts/linux-artifacts/*.AppImage artifacts/linux-artifacts/*.deb artifacts/linux-artifacts/*.rpm artifacts/linux-artifacts/*.snap artifacts/linux-artifacts/*.tar.gz artifacts/linux-artifacts/*.pacman artifacts/linux-flatpak-artifacts/*.flatpak artifacts/windows-artifacts/*-setup.exe artifacts/windows-artifacts/*.msi artifacts/windows-artifacts/*.zip env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} publish-snap: name: Publish to Snapcraft Store needs: build runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/v') env: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }} steps: - name: Download snap artifact uses: actions/download-artifact@v4 with: name: linux-artifacts path: artifacts - name: Setup Snapcraft uses: samuelmeuli/action-snapcraft@v3 - name: Publish all snaps to edge channel run: | # Find and publish all snap files for SNAP_FILE in artifacts/*.snap; do if [ -f "$SNAP_FILE" ]; then echo "Publishing: $SNAP_FILE" snapcraft upload --release=edge "$SNAP_FILE" fi done