diff --git a/.github/scripts/cloudsmith-backfill.mjs b/.github/scripts/cloudsmith-backfill.mjs new file mode 100644 index 00000000..0a378f41 --- /dev/null +++ b/.github/scripts/cloudsmith-backfill.mjs @@ -0,0 +1,606 @@ +import {createHash} from 'node:crypto'; +import {execFileSync} from 'node:child_process'; +import { + constants as fsConstants, + chmodSync, + copyFileSync, + createReadStream, + createWriteStream, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; +import {Readable} from 'node:stream'; +import {pipeline} from 'node:stream/promises'; + +const SOURCE_OWNER = 'LizardByte'; +const CLOUDSMITH_DESTINATION = 'lizardbyte/stable'; + +const UBUNTU_2004_UNLABELED_DEB_RELEASES = new Set(['0.14.0', '0.14.1', '0.15.0']); + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function packageNames(repository) { + if (!/^[0-9A-Za-z][0-9A-Za-z+._-]*$/.test(repository)) { + throw new Error(`Unsupported repository name: ${repository}`); + } + const deb = repository.toLowerCase(); + if (!/^[a-z0-9][a-z0-9+.-]+$/.test(deb)) { + throw new Error(`${repository} cannot be converted to a valid Debian package name.`); + } + return {deb, rpm: repository}; +} + +/** + * Return a tag without Sunshine's leading v. + * + * @param {string} tag Release tag. + * @returns {string} Package version. + */ +export function versionFromTag(tag) { + const version = tag.startsWith('v') ? tag.slice(1) : tag; + if (!/^[0-9][0-9A-Za-z.+~]*$/.test(version)) { + throw new Error(`Unsupported release tag: ${tag}`); + } + return version; +} + +/** + * Select published stable releases, optionally limiting the result to one tag. + * + * @param {Array} releases GitHub release objects. + * @param {string} releaseTag Optional exact tag. + * @returns {Array} Stable releases in oldest-first order. + */ +export function selectStableReleases(releases, releaseTag = '') { + const stable = releases.filter((release) => ( + !release.draft && !release.prerelease && release.published_at + )); + const selected = releaseTag + ? stable.filter((release) => release.tag_name === releaseTag) + : stable; + + if (releaseTag && selected.length !== 1) { + throw new Error(`Tag ${releaseTag} is not a published stable release.`); + } + + return selected.toSorted((left, right) => ( + Date.parse(left.published_at) - Date.parse(right.published_at) + )); +} + +/** + * Infer a historical DEB's repository target from its release-asset filename. + * + * @param {string} assetName GitHub release asset name. + * @param {string} tag Release tag. + * @param {string} repository GitHub repository and package name. + * @returns {{distro: string, release: string}} Cloudsmith target. + */ +export function classifyDeb(assetName, tag, repository = 'Sunshine') { + const name = assetName.toLowerCase(); + const packageName = packageNames(repository).deb; + const prefix = escapeRegExp(packageName); + let match = name.match(new RegExp(`^${prefix}_[^_]+\\+(debian|ubuntu)([^_]+)_[^_]+\\.deb$`)); + if (match) { + return {distro: match[1], release: match[2]}; + } + + match = name.match(new RegExp(`^${prefix}-(debian|ubuntu)-([a-z0-9.]+)-(?:amd64|arm64)\\.deb$`)); + if (match) { + return {distro: match[1], release: match[2]}; + } + + match = name.match(new RegExp(`^${prefix}(?:[-_]?ubuntu)?[-_]?(\\d{2})[._-]?(\\d{2})\\.deb$`)); + if (match) { + return {distro: 'ubuntu', release: `${match[1]}.${match[2]}`}; + } + + if (repository === 'Sunshine' && name === 'sunshine-debian.deb') { + return {distro: 'debian', release: 'bullseye'}; + } + + const version = versionFromTag(tag); + if ( + repository === 'Sunshine' + && name === 'sunshine.deb' + && UBUNTU_2004_UNLABELED_DEB_RELEASES.has(version) + ) { + return {distro: 'ubuntu', release: '20.04'}; + } + + throw new Error(`Cannot infer a DEB target for ${tag}/${assetName}.`); +} + +/** + * Infer a historical RPM's repository target from its release-asset filename. + * + * @param {string} assetName GitHub release asset name. + * @param {string} repository GitHub repository and package name. + * @returns {{distro: string, release: string, releaseSuffix: string, generic: boolean}} Cloudsmith target. + */ +export function classifyRpm(assetName, repository = 'Sunshine') { + const name = assetName.toLowerCase(); + const packageName = packageNames(repository).deb; + const prefix = escapeRegExp(packageName); + let match = name.match(/\.fc(\d+)\.(?:x86_64|aarch64)\.rpm$/); + if (!match) { + match = name.match(new RegExp(`^${prefix}-fedora-(\\d+)-(?:amd64|arm64)\\.rpm$`)); + } + if (match) { + return { + distro: 'fedora', + release: match[1], + releaseSuffix: `1.fc${match[1]}`, + generic: false, + }; + } + + match = name.match(/\.suse\.lp(\d{2})(\d)\.(?:x86_64|aarch64)\.rpm$/); + if (match) { + return { + distro: 'opensuse', + release: `${Number(match[1])}.${match[2]}`, + releaseSuffix: `1.suse.lp${match[1]}${match[2]}`, + generic: false, + }; + } + + if (/\.suse\.tw\.(?:x86_64|aarch64)\.rpm$/.test(name)) { + return { + distro: 'opensuse', + release: 'tumbleweed', + releaseSuffix: '1.suse.tw', + generic: false, + }; + } + + if (name === `${packageName}.rpm`) { + return { + distro: 'any-distro', + release: 'any-version', + releaseSuffix: '1', + generic: true, + }; + } + + throw new Error(`Cannot infer an RPM target for ${assetName}.`); +} + +/** + * Construct the corrected Debian version. + * + * @param {string} tag Release tag. + * @param {{distro: string, release: string}} target Distribution target. + * @returns {string} Debian version. + */ +export function debianVersion(tag, target) { + return `${versionFromTag(tag)}-1+${target.distro}${target.release}`; +} + +/** + * Construct the canonical lowercase DEB filename. + * + * @param {string} version Embedded Debian version. + * @param {string} architecture Debian architecture. + * @param {string} packageName Lowercase Debian package name. + * @returns {string} Canonical filename. + */ +export function canonicalDebFilename(version, architecture, packageName = 'sunshine') { + return `${packageName}_${version}_${architecture}.deb`; +} + +/** + * Construct the canonical, case-sensitive RPM filename. + * + * @param {string} version Embedded RPM version. + * @param {string} release Embedded RPM release. + * @param {string} architecture RPM architecture. + * @param {string} packageName Case-sensitive RPM package name. + * @returns {string} Canonical filename. + */ +export function canonicalRpmFilename(version, release, architecture, packageName = 'Sunshine') { + return `${packageName}-${version}-${release}.${architecture}.rpm`; +} + +/** + * Replace required fields in a Debian control file. + * + * @param {string} control Existing control text. + * @param {{Package: string, Version: string}} fields Replacement fields. + * @returns {string} Corrected control text. + */ +export function replaceDebControlFields(control, fields) { + let corrected = control; + for (const [field, value] of Object.entries(fields)) { + const expression = new RegExp(`^${field}:.*$`, 'm'); + if (!expression.test(corrected)) { + throw new Error(`DEB control file is missing ${field}.`); + } + corrected = corrected.replace(expression, `${field}: ${value}`); + } + return corrected; +} + +function command(executable, args, options = {}) { + const output = execFileSync(executable, args, { + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + ...options, + }); + return typeof output === 'string' ? output.trim() : ''; +} + +async function sha256File(filename) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(filename)) { + hash.update(chunk); + } + return hash.digest('hex'); +} + +async function downloadAsset(asset, destination, token) { + const response = await fetch(asset.url, { + headers: { + Accept: 'application/octet-stream', + Authorization: `Bearer ${token}`, + 'User-Agent': 'LizardByte-Cloudsmith-backfill', + 'X-GitHub-Api-Version': '2022-11-28', + }, + redirect: 'follow', + }); + if (!response.ok || !response.body) { + throw new Error(`Failed to download ${asset.name}: HTTP ${response.status}.`); + } + + const partial = `${destination}.part`; + await pipeline(Readable.fromWeb(response.body), createWriteStream(partial, {flags: 'wx'})); + const size = statSync(partial).size; + if (size !== asset.size) { + unlinkSync(partial); + throw new Error(`Downloaded ${asset.name} is ${size} bytes; expected ${asset.size}.`); + } + renameSync(partial, destination); +} + +function queryDeb(filename) { + return { + name: command('dpkg-deb', ['--field', filename, 'Package']), + version: command('dpkg-deb', ['--field', filename, 'Version']), + architecture: command('dpkg-deb', ['--field', filename, 'Architecture']), + }; +} + +function queryRpm(filename) { + const output = command('rpm', [ + '-qp', + '--qf', + '%{NAME}\u001f%{VERSION}\u001f%{RELEASE}\u001f%{ARCH}', + filename, + ]); + const [name, version, release, architecture] = output.split('\u001f'); + return {name, version, release, architecture}; +} + +function findFiles(directory, predicate) { + const matches = []; + for (const entry of readdirSync(directory, {withFileTypes: true})) { + const filename = path.join(directory, entry.name); + if (entry.isDirectory()) { + matches.push(...findFiles(filename, predicate)); + } else if (entry.isFile() && predicate(filename)) { + matches.push(filename); + } + } + return matches; +} + +export async function prepareDeb({source, destinationDirectory, release, target, repository = 'Sunshine'}) { + const original = queryDeb(source); + const names = packageNames(repository); + if (!/^[a-z0-9][a-z0-9-]*$/.test(original.architecture)) { + throw new Error(`Unsupported Debian architecture: ${original.architecture}`); + } + const desired = { + name: names.deb, + version: debianVersion(release.tag_name, target), + architecture: original.architecture, + }; + const filename = path.join( + destinationDirectory, + canonicalDebFilename(desired.version, desired.architecture, names.deb), + ); + if (existsSync(filename)) { + throw new Error(`Two assets normalize to ${path.basename(filename)}.`); + } + + const rebuilt = original.name !== desired.name || original.version !== desired.version; + if (rebuilt) { + const extractDirectory = `${source}.root`; + try { + command('dpkg-deb', ['--raw-extract', source, extractDirectory]); + const controlFile = path.join(extractDirectory, 'DEBIAN', 'control'); + const control = readFileSync(controlFile, 'utf8'); + writeFileSync(controlFile, replaceDebControlFields(control, { + Package: desired.name, + Version: desired.version, + })); + for (const maintainerScript of ['config', 'postinst', 'postrm', 'preinst', 'prerm']) { + const scriptFile = path.join(extractDirectory, 'DEBIAN', maintainerScript); + if (existsSync(scriptFile)) { + chmodSync(scriptFile, 0o755); + } + } + command('dpkg-deb', [ + '--root-owner-group', + '-Zxz', + '--build', + extractDirectory, + filename, + ], { + env: { + ...process.env, + SOURCE_DATE_EPOCH: String(Math.floor(Date.parse(release.published_at) / 1000)), + }, + }); + } finally { + rmSync(extractDirectory, {recursive: true, force: true}); + } + } else { + copyFileSync(source, filename, fsConstants.COPYFILE_EXCL); + } + + command('dpkg-deb', ['--info', filename], {stdio: 'ignore'}); + command('dpkg-deb', ['--contents', filename], {stdio: 'ignore'}); + const normalized = queryDeb(filename); + if (JSON.stringify(normalized) !== JSON.stringify(desired)) { + throw new Error(`Rebuilt DEB metadata is invalid for ${path.basename(filename)}.`); + } + + return {filename, original, normalized, rebuilt}; +} + +export async function prepareRpm({source, destinationDirectory, release, target, repository = 'Sunshine'}) { + command('rpm', ['--checksig', '--nosignature', source]); + const original = queryRpm(source); + const names = packageNames(repository); + if (!/^(?:aarch64|x86_64)$/.test(original.architecture)) { + throw new Error(`Unsupported RPM architecture: ${original.architecture}`); + } + const desired = { + name: names.rpm, + version: versionFromTag(release.tag_name), + release: target.releaseSuffix, + architecture: original.architecture, + }; + const filename = path.join( + destinationDirectory, + canonicalRpmFilename(desired.version, desired.release, desired.architecture, names.rpm), + ); + if (existsSync(filename)) { + throw new Error(`Two assets normalize to ${path.basename(filename)}.`); + } + + const rebuilt = original.name !== desired.name + || original.version !== desired.version + || original.release !== desired.release; + if (rebuilt) { + const rebuildDirectory = `${source}.rpmrebuild`; + mkdirSync(rebuildDirectory, {recursive: true}); + try { + const filter = [ + 'sed', + `-e "s/^Name:.*/Name: ${desired.name}/"`, + `-e "s/^Version:.*/Version: ${desired.version}/"`, + `-e "s/^Release:.*/Release: ${desired.release}/"`, + ].join(' '); + command('rpmrebuild', [ + '--package', + '--batch', + '--notest-install', + `--directory=${rebuildDirectory}`, + `--change-spec-preamble=${filter}`, + source, + ], { + env: { + ...process.env, + SOURCE_DATE_EPOCH: String(Math.floor(Date.parse(release.published_at) / 1000)), + }, + stdio: 'inherit', + }); + const rebuiltFiles = findFiles( + rebuildDirectory, + (candidate) => candidate.endsWith('.rpm') && !candidate.endsWith('.src.rpm'), + ); + if (rebuiltFiles.length !== 1) { + throw new Error(`rpmrebuild produced ${rebuiltFiles.length} binary RPMs for ${source}.`); + } + copyFileSync(rebuiltFiles[0], filename, fsConstants.COPYFILE_EXCL); + } finally { + rmSync(rebuildDirectory, {recursive: true, force: true}); + } + } else { + copyFileSync(source, filename, fsConstants.COPYFILE_EXCL); + } + + command('rpm', ['--checksig', '--nosignature', filename]); + const normalized = queryRpm(filename); + if (JSON.stringify(normalized) !== JSON.stringify(desired)) { + throw new Error(`Rebuilt RPM metadata is invalid for ${path.basename(filename)}.`); + } + + if (rebuilt && original.name === 'sunshine') { + const provides = command('rpm', ['-qp', '--provides', filename]).split('\n'); + if (!provides.some((item) => item.startsWith('sunshine = '))) { + throw new Error('Rebuilt RPM no longer provides the historical sunshine package name.'); + } + } + + return {filename, original, normalized, rebuilt}; +} + +/** + * Download, validate, correct, and stage Sunshine's stable historical packages. + * + * @param {object} options Runtime options supplied by actions/github-script. + * @returns {Promise>} Audit manifest entries. + */ +export async function prepareBackfill({ + github, + core, + token, + workspace, + repository, + releaseTag = '', +}) { + packageNames(repository); + if (!token) { + throw new Error('A GitHub token is required to download release assets.'); + } + const releases = await github.paginate(github.rest.repos.listReleases, { + owner: SOURCE_OWNER, + repo: repository, + per_page: 100, + }); + const selected = selectStableReleases(releases, releaseTag.trim()); + if (selected.length === 0) { + throw new Error('No published stable Sunshine releases were found.'); + } + + const root = path.join(workspace, 'artifacts', 'cloudsmith'); + const downloads = path.join(root, 'downloads'); + const detected = path.join(root, 'detected'); + const generic = path.join(root, 'generic'); + const manifestFile = path.join(root, 'manifest.json'); + for (const directory of [downloads, detected, generic]) { + mkdirSync(directory, {recursive: true}); + } + + const manifest = []; + for (const release of selected) { + const assets = release.assets.filter((asset) => /\.(?:deb|rpm)$/i.test(asset.name)); + if (assets.length === 0) { + core.info(`${release.tag_name}: no DEB or RPM assets`); + continue; + } + + core.startGroup(`${release.tag_name}: ${assets.length} package assets`); + try { + const releaseDirectory = path.join(downloads, release.tag_name.replace(/[^0-9A-Za-z_.-]/g, '_')); + mkdirSync(releaseDirectory, {recursive: true}); + for (const asset of assets) { + const safeName = path.basename(asset.name); + if (safeName !== asset.name) { + throw new Error(`Unsafe release asset name: ${asset.name}`); + } + const source = path.join(releaseDirectory, safeName); + core.info(`Downloading ${release.tag_name}/${asset.name}`); + await downloadAsset(asset, source, token); + const sourceSha256 = await sha256File(source); + if (asset.digest?.startsWith('sha256:') && asset.digest.slice(7) !== sourceSha256) { + throw new Error(`GitHub digest mismatch for ${release.tag_name}/${asset.name}.`); + } + + try { + const isDeb = asset.name.toLowerCase().endsWith('.deb'); + const target = isDeb + ? classifyDeb(asset.name, release.tag_name, repository) + : classifyRpm(asset.name, repository); + const prepared = isDeb + ? await prepareDeb({source, destinationDirectory: detected, release, target, repository}) + : await prepareRpm({ + source, + destinationDirectory: target.generic ? generic : detected, + release, + target, + repository, + }); + const stagedSha256 = await sha256File(prepared.filename); + manifest.push({ + release: { + tag: release.tag_name, + publishedAt: release.published_at, + repository: `${SOURCE_OWNER}/${repository}`, + }, + source: { + assetId: asset.id, + name: asset.name, + size: asset.size, + githubDigest: asset.digest ?? null, + sha256: sourceSha256, + metadata: prepared.original, + }, + staged: { + name: path.basename(prepared.filename), + size: statSync(prepared.filename).size, + sha256: stagedSha256, + metadata: prepared.normalized, + rebuilt: prepared.rebuilt, + }, + cloudsmith: { + destination: `${CLOUDSMITH_DESTINATION}/${target.distro}/${target.release}`, + automaticUpload: !target.generic, + }, + }); + writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`); + } finally { + unlinkSync(source); + } + } + } finally { + core.endGroup(); + } + } + + if (manifest.length === 0) { + throw new Error('No stable DEB or RPM assets were staged.'); + } + core.info(`Staged and validated ${manifest.length} packages.`); + core.setOutput('package_count', manifest.length); + core.setOutput('manifest', manifestFile); + return manifest; +} + +/** + * Upload the old distro-neutral RPMs after the shared action configures the CLI. + * + * @param {object} options Runtime options supplied by actions/github-script. + * @returns {Promise} Number of generic RPMs found. + */ +export async function uploadGenericRpms({core, directory, publish, republish}) { + const packages = existsSync(directory) + ? readdirSync(directory) + .filter((name) => name.endsWith('.rpm')) + .toSorted() + .map((name) => path.join(directory, name)) + : []; + + for (const filename of packages) { + const args = [ + 'push', + 'rpm', + `${CLOUDSMITH_DESTINATION}/any-distro/any-version`, + filename, + '--no-wait-for-sync', + ]; + if (republish) { + args.push('--republish'); + } + if (publish) { + command('cloudsmith', args, {stdio: 'inherit'}); + } else { + core.info(`[dry-run] cloudsmith ${args.join(' ')}`); + } + } + core.info(`${publish ? 'Published' : 'Planned'} ${packages.length} distro-neutral RPMs.`); + return packages.length; +} diff --git a/.github/workflows/__cloudsmith-backfill.yml b/.github/workflows/__cloudsmith-backfill.yml new file mode 100644 index 00000000..391e26d9 --- /dev/null +++ b/.github/workflows/__cloudsmith-backfill.yml @@ -0,0 +1,118 @@ +--- +# Validate and backfill Sunshine's historical stable DEB and RPM release assets. + +name: Cloudsmith Backfill +permissions: + contents: read + +on: + workflow_dispatch: + inputs: + publish: + description: Publish packages to Cloudsmith instead of performing a dry run + required: true + type: boolean + default: false + release_tag: + description: Optional exact stable tag; leave empty to process every stable release + required: false + type: string + repository: + description: LizardByte GitHub repository name + required: true + type: string + republish: + description: Replace matching Cloudsmith packages when publishing + required: true + type: boolean + default: false + +concurrency: + group: cloudsmith-backfill + cancel-in-progress: false + +jobs: + backfill: + name: Backfill stable packages + if: github.repository == 'LizardByte/.github' + runs-on: ubuntu-latest + timeout-minutes: 360 + container: + image: fedora:latest + steps: + - name: Install package tools + run: dnf --assumeyes --quiet install dpkg git rpm-build rpmrebuild + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Download, validate, and normalize packages + id: prepare + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + RELEASE_TAG: ${{ inputs.release_tag }} + SOURCE_REPOSITORY: ${{ inputs.repository }} + SOURCE_TOKEN: ${{ github.token }} + with: + script: | + const path = require('node:path'); + const {pathToFileURL} = require('node:url'); + const modulePath = path.join( + process.env.GITHUB_WORKSPACE, + '.github/scripts/cloudsmith-backfill.mjs', + ); + const {prepareBackfill} = await import(pathToFileURL(modulePath).href); + await prepareBackfill({ + github, + core, + token: process.env.SOURCE_TOKEN, + workspace: process.env.GITHUB_WORKSPACE, + repository: process.env.SOURCE_REPOSITORY, + releaseTag: process.env.RELEASE_TAG, + }); + + - name: Upload distro-specific packages + uses: LizardByte/actions/actions/cloudsmith_upload@f697f5b43d7787eca2cef681d314a2093cfa15e3 # master + with: + api_key: ${{ secrets.CLOUDSMITH_API_KEY }} + dry_run: ${{ !inputs.publish }} + fail_on_no_packages: false + fail_on_unmatched: true + owner: lizardbyte + package_path: artifacts/cloudsmith/detected + repository: stable + republish: ${{ inputs.republish }} + skip_unsupported: false + wait_for_sync: false + + - name: Upload distro-neutral RPMs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PUBLISH: ${{ inputs.publish }} + REPUBLISH: ${{ inputs.republish }} + with: + script: | + const path = require('node:path'); + const {pathToFileURL} = require('node:url'); + const modulePath = path.join( + process.env.GITHUB_WORKSPACE, + '.github/scripts/cloudsmith-backfill.mjs', + ); + const {uploadGenericRpms} = await import(pathToFileURL(modulePath).href); + await uploadGenericRpms({ + core, + directory: path.join( + process.env.GITHUB_WORKSPACE, + 'artifacts/cloudsmith/generic', + ), + publish: process.env.PUBLISH === 'true', + republish: process.env.REPUBLISH === 'true', + }); + + - name: Upload audit manifest + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cloudsmith-backfill-manifest-${{ github.run_id }} + path: artifacts/cloudsmith/manifest.json + if-no-files-found: warn diff --git a/tests/cloudsmith-backfill.test.mjs b/tests/cloudsmith-backfill.test.mjs new file mode 100644 index 00000000..b67a9de4 --- /dev/null +++ b/tests/cloudsmith-backfill.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + canonicalDebFilename, + canonicalRpmFilename, + classifyDeb, + classifyRpm, + debianVersion, + replaceDebControlFields, + selectStableReleases, + versionFromTag, +} from '../.github/scripts/cloudsmith-backfill.mjs'; + +test('selectStableReleases excludes drafts and prereleases and sorts oldest first', () => { + const releases = [ + {tag_name: 'v3.0.0', published_at: '2026-03-01T00:00:00Z', draft: false, prerelease: true}, + {tag_name: 'v2.0.0', published_at: '2026-02-01T00:00:00Z', draft: false, prerelease: false}, + {tag_name: 'v0.0.0', published_at: null, draft: true, prerelease: false}, + {tag_name: 'v1.0.0', published_at: '2026-01-01T00:00:00Z', draft: false, prerelease: false}, + ]; + + assert.deepEqual( + selectStableReleases(releases).map((release) => release.tag_name), + ['v1.0.0', 'v2.0.0'], + ); + assert.deepEqual( + selectStableReleases(releases, 'v2.0.0').map((release) => release.tag_name), + ['v2.0.0'], + ); + assert.throws( + () => selectStableReleases(releases, 'v3.0.0'), + /not a published stable release/, + ); +}); + +test('versionFromTag accepts Sunshine versions and rejects unsafe tags', () => { + assert.equal(versionFromTag('v0.17.0'), '0.17.0'); + assert.equal(versionFromTag('v2026.516.143833'), '2026.516.143833'); + assert.throws(() => versionFromTag('release/1.0'), /Unsupported release tag/); +}); + +test('classifyDeb covers every historical Sunshine naming generation', () => { + const cases = [ + ['sunshine20.04.deb', 'v0.7.0', {distro: 'ubuntu', release: '20.04'}], + ['sunshine21-04.deb', 'v0.7.1', {distro: 'ubuntu', release: '21.04'}], + ['sunshine-2004.deb', 'v0.11.1', {distro: 'ubuntu', release: '20.04'}], + ['sunshine-ubuntu_21_10.deb', 'v0.13.0', {distro: 'ubuntu', release: '21.10'}], + ['sunshine-22.04.deb', 'v0.17.0', {distro: 'ubuntu', release: '22.04'}], + ['sunshine-debian.deb', 'v0.10.1', {distro: 'debian', release: 'bullseye'}], + ['sunshine.deb', 'v0.14.0', {distro: 'ubuntu', release: '20.04'}], + ['sunshine-debian-bookworm-amd64.deb', 'v0.23.1', {distro: 'debian', release: 'bookworm'}], + ['sunshine-ubuntu-24.04-arm64.deb', 'v2026.516.143833', {distro: 'ubuntu', release: '24.04'}], + [ + 'sunshine_2026.516.143833-1+debiantrixie_amd64.deb', + 'v2026.516.143833', + {distro: 'debian', release: 'trixie'}, + ], + ]; + + for (const [filename, tag, expected] of cases) { + assert.deepEqual(classifyDeb(filename, tag), expected, filename); + } + assert.throws(() => classifyDeb('sunshine.deb', 'v0.13.0'), /Cannot infer/); +}); + +test('classifyRpm covers Fedora, openSUSE, and distro-neutral packages', () => { + assert.deepEqual(classifyRpm('sunshine-fedora-37-amd64.rpm'), { + distro: 'fedora', release: '37', releaseSuffix: '1.fc37', generic: false, + }); + assert.deepEqual(classifyRpm('Sunshine-2026.516.143833-1.fc44.aarch64.rpm'), { + distro: 'fedora', release: '44', releaseSuffix: '1.fc44', generic: false, + }); + assert.deepEqual(classifyRpm('Sunshine-2026.516.143833-1.suse.lp156.x86_64.rpm'), { + distro: 'opensuse', release: '15.6', releaseSuffix: '1.suse.lp156', generic: false, + }); + assert.deepEqual(classifyRpm('Sunshine-2026.516.143833-1.suse.tw.x86_64.rpm'), { + distro: 'opensuse', release: 'tumbleweed', releaseSuffix: '1.suse.tw', generic: false, + }); + assert.deepEqual(classifyRpm('sunshine.rpm'), { + distro: 'any-distro', release: 'any-version', releaseSuffix: '1', generic: true, + }); + assert.throws(() => classifyRpm('unknown.rpm'), /Cannot infer/); +}); + +test('explicit distro filenames work for another LizardByte repository', () => { + assert.deepEqual(classifyDeb('helloworld-ubuntu-24.04-amd64.deb', 'v1.0.0', 'HelloWorld'), { + distro: 'ubuntu', release: '24.04', + }); + assert.deepEqual(classifyRpm('HelloWorld-1.0.0-1.fc44.x86_64.rpm', 'HelloWorld'), { + distro: 'fedora', release: '44', releaseSuffix: '1.fc44', generic: false, + }); + assert.equal( + canonicalDebFilename('1.0.0-1+ubuntu24.04', 'amd64', 'helloworld'), + 'helloworld_1.0.0-1+ubuntu24.04_amd64.deb', + ); + assert.equal( + canonicalRpmFilename('1.0.0', '1.fc44', 'x86_64', 'HelloWorld'), + 'HelloWorld-1.0.0-1.fc44.x86_64.rpm', + ); +}); + +test('package names follow the native DEB and RPM conventions', () => { + const target = {distro: 'ubuntu', release: '22.04'}; + const version = debianVersion('v0.17.0', target); + assert.equal(version, '0.17.0-1+ubuntu22.04'); + assert.equal(canonicalDebFilename(version, 'amd64'), 'sunshine_0.17.0-1+ubuntu22.04_amd64.deb'); + assert.equal(canonicalRpmFilename('0.17.0', '1.fc37', 'x86_64'), 'Sunshine-0.17.0-1.fc37.x86_64.rpm'); +}); + +test('replaceDebControlFields corrects package identity and version only', () => { + const control = 'Package: Sunshine\nVersion: 0.7.3\nArchitecture: amd64\nDescription: Sunshine\n'; + assert.equal( + replaceDebControlFields(control, {Package: 'sunshine', Version: '0.7.0-1+ubuntu20.04'}), + 'Package: sunshine\nVersion: 0.7.0-1+ubuntu20.04\nArchitecture: amd64\nDescription: Sunshine\n', + ); + assert.throws( + () => replaceDebControlFields('Package: sunshine\n', {Package: 'sunshine', Version: '1.0'}), + /missing Version/, + ); +});