From 8547da62fe5483837ea49f41c511788c222f8a45 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:14:04 -0400 Subject: [PATCH] ci: add Cloudsmith backfill workflow Introduce a backfill script and dispatchable workflow to download historical stable DEB/RPM release assets, validate them, normalize package metadata and filenames, and stage or publish them to Cloudsmith. Add tests covering stable release selection, version parsing, package classification, canonical naming, and Debian control-field rewriting. --- .github/scripts/cloudsmith-backfill.mjs | 606 ++++++++++++++++++++ .github/workflows/__cloudsmith-backfill.yml | 118 ++++ tests/cloudsmith-backfill.test.mjs | 121 ++++ 3 files changed, 845 insertions(+) create mode 100644 .github/scripts/cloudsmith-backfill.mjs create mode 100644 .github/workflows/__cloudsmith-backfill.yml create mode 100644 tests/cloudsmith-backfill.test.mjs 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/, + ); +});