name: Build Image run-name: Build Image (${{ inputs.distro || 'all' }}) on: workflow_dispatch: inputs: distro: description: 'Distribution' default: 'ubuntu2604' type: choice options: [all, ubuntu2604, arch, cachyos, fedora, proxmox, debian, bazzite, bazzite-deck, steamos] workflow_call: inputs: distro: type: string default: 'ubuntu2604' secrets: R2_ACCESS_KEY_ID: required: true R2_SECRET_ACCESS_KEY: required: true R2_ENDPOINT: required: true R2_BUCKET: required: true jobs: matrix: runs-on: self-hosted outputs: distros: ${{ steps.set.outputs.distros }} steps: - id: set run: | INPUT="${{ inputs.distro || 'ubuntu2604' }}" if [ "$INPUT" = "all" ]; then echo 'distros=["ubuntu2604","arch","cachyos","fedora","proxmox","debian","bazzite","bazzite-deck","steamos"]' >> "$GITHUB_OUTPUT" else echo "distros=[\"$INPUT\"]" >> "$GITHUB_OUTPUT" fi build: needs: matrix runs-on: self-hosted timeout-minutes: 720 permissions: contents: write strategy: fail-fast: true max-parallel: 5 matrix: distro: ${{ fromJson(needs.matrix.outputs.distros) }} env: CCACHE_DIR: /home/opc/ccache steps: - name: Clean image workspace run: | set -euo pipefail if [ -z "${GITHUB_WORKSPACE:-}" ] || [ "$GITHUB_WORKSPACE" = "/" ]; then echo "Invalid GITHUB_WORKSPACE" >&2 exit 1 fi # A previous run can leave loop mounts (kpartx / losetup -P) and # bind mounts (distrobuilder chroots) live inside image/work/. # rm -rf then trips on "Directory not empty" because it can't # cross the mountpoint. Unmount everything under the workspace # first, then detach any loop devices still backed by files in # the workspace, then rm. WS="$GITHUB_WORKSPACE" # Lazy-unmount any mount whose target is under the workspace. # Reverse order so deepest mounts go first. for m in $(findmnt -rn -o TARGET | grep "^$WS/" | sort -r || true); do echo "unmounting stale $m" sudo umount -l "$m" || true done # Detach any loop device whose backing file lives under the # workspace (or shows '(deleted)' against one — that means the # backing file was removed but the loop wasn't released). for l in $(sudo losetup -l --noheadings -O NAME,BACK-FILE 2>/dev/null \ | awk -v ws="$WS" '$2 ~ "^"ws || $2 ~ ws"/" || $2 ~ "\\(deleted\\)$" {print $1}'); do echo "detaching stale loop $l" sudo losetup -d "$l" || true done sudo rm -rf "$WS/image" - name: Checkout image builder uses: actions/checkout@v6 with: path: image - name: Build ${{ matrix.distro }} image run: | bash image/build_image.sh --distro "${{ matrix.distro }}" - name: Set version info id: version run: | psha=$(git -C image/work/ps5-linux-patches rev-parse --short HEAD 2>/dev/null || echo "unknown") kver=$(cat image/linux-bin/VERSION 2>/dev/null || echo "unknown") ts=$(date -u +%Y%m%d-%H%M%S) echo "psha=$psha" >> "$GITHUB_OUTPUT" echo "kver=$kver" >> "$GITHUB_OUTPUT" echo "ts=$ts" >> "$GITHUB_OUTPUT" - name: Report run: | ls -lh image/output/*.img sha256sum image/output/*.img - name: Compress image run: | xz -T0 -4 -c image/output/ps5-${{ matrix.distro }}.img \ > image/output/ps5-${{ matrix.distro }}.img.xz - name: Upload to R2 if: github.event_name != 'pull_request' && github.event_name != 'pull_request_target' env: RCLONE_CONFIG_R2_TYPE: s3 RCLONE_CONFIG_R2_PROVIDER: Cloudflare RCLONE_CONFIG_R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} RCLONE_CONFIG_R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} RCLONE_CONFIG_R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} RCLONE_CONFIG_R2_NO_CHECK_BUCKET: true run: | rclone copyto image/output/ps5-${{ matrix.distro }}.img.xz \ "r2:${{ secrets.R2_BUCKET }}/ps5-${{ matrix.distro }}.img.xz" - name: Save build metadata run: | mkdir -p meta sha256sum image/output/ps5-${{ matrix.distro }}.img.xz \ | sed 's|image/output/||' > meta/${{ matrix.distro }}.sha256 echo "${{ steps.version.outputs.kver }}" > meta/kver echo "${{ steps.version.outputs.psha }}" > meta/psha echo "${{ steps.version.outputs.ts }}" > meta/ts - name: Upload build metadata uses: actions/upload-artifact@v6 with: name: meta-${{ matrix.distro }} path: meta/ retention-days: 1 release: needs: build runs-on: self-hosted if: github.event_name != 'pull_request' && github.event_name != 'pull_request_target' permissions: contents: write steps: - name: Download build metadata uses: actions/download-artifact@v6 with: pattern: meta-* path: meta merge-multiple: true - name: Prepare release body id: body uses: actions/github-script@v8 with: script: | const fs = require('fs'); const path = require('path'); const kver = fs.readFileSync('meta/kver', 'utf8').trim(); const psha = fs.readFileSync('meta/psha', 'utf8').trim(); const ts = fs.readFileSync('meta/ts', 'utf8').trim(); // Collect new checksums from this run const newSums = {}; for (const f of fs.readdirSync('meta').filter(f => f.endsWith('.sha256'))) { const distro = f.replace('.sha256', ''); newSums[distro] = fs.readFileSync(path.join('meta', f), 'utf8').trim(); } // All known distros and their display names (order = table order) const R2 = 'https://pub-561df4012f1a46fbbdf618d5cc5941f6.r2.dev'; const ALL_DISTROS = [ { key: 'ubuntu2604', label: 'Ubuntu 26.04', file: 'ps5-ubuntu2604.img.xz' }, { key: 'arch', label: 'Arch', file: 'ps5-arch.img.xz' }, { key: 'cachyos', label: 'CachyOS', file: 'ps5-cachyos.img.xz' }, { key: 'fedora', label: 'Fedora 44', file: 'ps5-fedora.img.xz' }, { key: 'proxmox', label: 'Proxmox VE 8', file: 'ps5-proxmox.img.xz' }, { key: 'debian', label: 'Debian 12', file: 'ps5-debian.img.xz' }, { key: 'bazzite', label: 'Bazzite (KDE gaming)', file: 'ps5-bazzite.img.xz' }, { key: 'bazzite-deck', label: 'Bazzite Deck UI', file: 'ps5-bazzite-deck.img.xz' }, { key: 'steamos', label: 'SteamOS 3 (Holo)', file: 'ps5-steamos.img.xz' }, ]; // Fetch existing release body to preserve distros not built this run const existingSums = {}; try { const { data: rel } = await github.rest.repos.getReleaseByTag({ ...context.repo, tag: 'latest', }); // Extract existing checksum lines (format: "hash filename") const sumBlock = (rel.body || '').match(/```\n([\s\S]*?)```/); if (sumBlock) { for (const line of sumBlock[1].trim().split('\n')) { const m = line.match(/^(\w+)\s+ps5-(\S+)\.img\.xz$/); if (m) existingSums[m[2]] = line.trim(); } } // Also preserve distros that appear in the table even without checksums for (const d of ALL_DISTROS) { if (rel.body && rel.body.includes(d.file) && !existingSums[d.key]) { existingSums[d.key] = ''; } } } catch (e) { core.info('No existing release found, starting fresh'); } // Merge: new checksums override existing ones const mergedSums = { ...existingSums }; for (const [k, v] of Object.entries(newSums)) { mergedSums[k] = v; } // Build the table — only include distros that have been built at least once const rows = ALL_DISTROS .filter(d => d.key in mergedSums || d.key in newSums) .map(d => `| ${d.label} | [\`${d.file}\`](${R2}/${d.file}) |`); // Combine all checksum lines const allSumLines = ALL_DISTROS .filter(d => mergedSums[d.key]) .map(d => mergedSums[d.key]) .join('\n'); const body = [ 'PS5 Linux images — built from latest `main`.', '', `Kernel: \`${kver}\``, `Patches: [\`${psha}\`](https://github.com/ps5-linux/ps5-linux-patches/commit/${psha})`, `Built: \`${ts}\``, '', '| Image | Download |', '|-------|----------|', ...rows, '', '**SHA256 checksums:**', '```', allSumLines, '```', ].join('\n'); core.setOutput('text', body); - name: Move latest tag to current commit uses: actions/github-script@v8 with: script: | const ref = 'tags/latest'; try { await github.rest.git.updateRef({ ...context.repo, ref, sha: context.sha, force: true, }); } catch (e) { if (e.status !== 422) throw e; await github.rest.git.createRef({ ...context.repo, ref: 'refs/tags/latest', sha: context.sha, }); } - name: Create or update release uses: softprops/action-gh-release@v3 with: token: ${{ secrets.GITHUB_TOKEN }} tag_name: latest name: PS5 Linux Image (latest) body: ${{ steps.body.outputs.text }} make_latest: true - name: Refresh release publish date uses: actions/github-script@v8 with: script: | const { data: rel } = await github.rest.repos.getReleaseByTag({ ...context.repo, tag: 'latest', }); // Toggling draft off->on->off re-publishes, resetting published_at // to now while keeping the same release (reactions/comments survive). await github.rest.repos.updateRelease({ ...context.repo, release_id: rel.id, draft: true, }); await github.rest.repos.updateRelease({ ...context.repo, release_id: rel.id, draft: false, make_latest: 'true', }); core.info(`re-published release ${rel.id}`);