OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / scripts / resolve-native-release-asset.mjs

243 lines · javascript ref:
#!/usr/bin/env node

import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'

const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))

const args = parseArgs(process.argv.slice(2))
const tag = normalizeTag(args.tag || `v${pkg.version}`)
const version = versionFromTag(tag)
const repo = args.repo || process.env.GH_REPO || 'bigdestiny2/pearbrowser-desktop'
const platform = normalizePlatform(args.platform || process.platform)
const arch = normalizeArch(args.arch || process.arch)

let release
try {
  release = args.fixture ? loadFixture(args.fixture) : loadReleaseFromGithub(repo, tag)
} catch (err) {
  fail(err && err.message ? err.message : String(err))
}

let result
try {
  result = resolveReleaseAsset(release, { repo, tag, version, platform, arch })
} catch (err) {
  fail(err && err.message ? err.message : String(err))
}

if (args.json) printJson(result)
else printHuman(result)

function parseArgs (argv) {
  const parsed = {
    tag: '',
    repo: '',
    fixture: '',
    platform: '',
    arch: '',
    json: false
  }
  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i]
    if (arg === '--tag') parsed.tag = requireValue(argv, ++i, arg)
    else if (arg === '--repo') parsed.repo = requireValue(argv, ++i, arg)
    else if (arg === '--fixture') parsed.fixture = requireValue(argv, ++i, arg)
    else if (arg === '--platform') parsed.platform = requireValue(argv, ++i, arg)
    else if (arg === '--arch') parsed.arch = requireValue(argv, ++i, arg)
    else if (arg === '--json') parsed.json = true
    else if (arg === '-h' || arg === '--help') usage(0)
    else usage(2, `unknown argument: ${arg}`)
  }
  return parsed
}

function requireValue (argv, index, flag) {
  const value = argv[index] || ''
  if (!value || value.startsWith('--')) usage(2, `${flag} requires a value`)
  return value
}

function usage (code, message = '') {
  if (message) console.error(`error: ${message}`)
  console.error('usage: node scripts/resolve-native-release-asset.mjs [--tag v0.5.0] [--repo owner/repo] [--platform macos|windows|linux] [--arch x64|arm64] [--json]')
  console.error('       node scripts/resolve-native-release-asset.mjs --fixture release.json [--platform macos|windows|linux] [--arch x64|arm64] [--json]')
  process.exit(code)
}

function fail (message) {
  if (args.json) {
    console.log(JSON.stringify({
      ok: false,
      repo,
      tag,
      version,
      platform,
      arch,
      error: message
    }, null, 2))
  } else {
    console.error(`Native release asset resolver failed: ${message}`)
  }
  process.exit(1)
}

function normalizeTag (tag) {
  const normalized = String(tag || '').replace(/^refs\/tags\//, '')
  if (!/^v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(normalized)) {
    usage(2, `release tag must look like vX.Y.Z, got ${tag}`)
  }
  return normalized
}

function versionFromTag (tag) {
  return normalizeTag(tag).slice(1)
}

function normalizePlatform (value) {
  const platform = String(value || '').toLowerCase()
  if (platform === 'darwin' || platform === 'mac' || platform === 'macos') return 'macos'
  if (platform === 'win32' || platform === 'win' || platform === 'windows') return 'windows'
  if (platform === 'linux') return 'linux'
  usage(2, `unsupported platform: ${value}`)
}

function normalizeArch (value) {
  const arch = String(value || '').toLowerCase()
  if (arch === 'x64' || arch === 'amd64') return 'x64'
  if (arch === 'arm64' || arch === 'aarch64') return 'arm64'
  return arch
}

function loadFixture (path) {
  return JSON.parse(readFileSync(path, 'utf8'))
}

function loadReleaseFromGithub (repo, tag) {
  try {
    const out = execFileSync('gh', [
      'release',
      'view',
      tag,
      '--repo',
      repo,
      '--json',
      'tagName,isDraft,isPrerelease,assets'
    ], { encoding: 'utf8' })
    return JSON.parse(out)
  } catch (err) {
    const stderr = err?.stderr ? String(err.stderr).trim() : ''
    throw new Error(stderr || `could not load GitHub release ${repo}@${tag}`)
  }
}

function resolveReleaseAsset (release, options) {
  const assets = normalizeAssets(release?.assets)
  const names = new Set(assets.map((asset) => asset.name))
  const tagName = release?.tagName || release?.tag_name || ''
  if (tagName && tagName !== options.tag) {
    throw new Error(`release tagName ${tagName} does not match expected ${options.tag}`)
  }
  if (assets.length === 0) throw new Error('release has no attached assets')

  const prefix = `PearBrowser-${options.version}-${options.platform}-${options.arch}`
  const candidates = assets
    .filter((asset) => asset.name.startsWith(prefix))
    .filter((asset) => !asset.name.endsWith('.sha256'))
    .filter((asset) => isPrimaryArtifact(options.platform, asset.name))
    .sort((a, b) => {
      return artifactRank(options.platform, a.name) - artifactRank(options.platform, b.name) ||
        a.name.length - b.name.length ||
        a.name.localeCompare(b.name)
    })

  if (candidates.length === 0) {
    throw new Error(`no ${options.platform}/${options.arch} native artifact found for ${options.tag}`)
  }

  const bestRank = artifactRank(options.platform, candidates[0].name)
  const equallyPreferred = candidates.filter((candidate) => artifactRank(options.platform, candidate.name) === bestRank)
  if (equallyPreferred.length > 1) {
    throw new Error(`ambiguous ${options.platform}/${options.arch} native artifacts for ${options.tag}: ${equallyPreferred.map((candidate) => candidate.name).join(', ')}`)
  }

  const asset = candidates[0]
  if (asset.size <= 0) throw new Error(`${asset.name} is empty or missing a size`)

  const checksumName = `${asset.name}.sha256`
  const checksumAsset = assets.find((candidate) => candidate.name === checksumName)
  if (!checksumAsset) throw new Error(`missing SHA-256 sidecar for ${asset.name}`)
  if (checksumAsset.size <= 0) throw new Error(`${checksumName} is empty or missing a size`)

  return {
    ok: true,
    repo: options.repo,
    tag: options.tag,
    version: options.version,
    platform: options.platform,
    arch: options.arch,
    release: {
      tagName,
      isDraft: !!release?.isDraft,
      isPrerelease: !!release?.isPrerelease
    },
    asset: {
      name: asset.name,
      size: asset.size,
      url: asset.url || githubReleaseAssetUrl(options.repo, options.tag, asset.name)
    },
    checksum: {
      name: checksumName,
      size: checksumAsset.size,
      url: checksumAsset.url || githubReleaseAssetUrl(options.repo, options.tag, checksumName)
    },
    candidates: candidates.map((candidate) => candidate.name)
  }
}

function normalizeAssets (assets) {
  if (!Array.isArray(assets)) return []
  return assets
    .map((asset) => ({
      name: String(asset?.name || '').trim(),
      size: Number(asset?.size ?? asset?.sizeInBytes ?? asset?.size_bytes ?? 0),
      url: String(asset?.url || asset?.browserDownloadUrl || asset?.browser_download_url || '').trim()
    }))
    .filter((asset) => asset.name)
}

function isPrimaryArtifact (platform, name) {
  if (platform === 'macos') return /\.(?:dmg|pkg|app\.zip|zip)$/i.test(name)
  if (platform === 'windows') return /\.(?:exe|msix|msi|zip)$/i.test(name)
  if (platform === 'linux') return /\.(?:AppImage|deb|rpm|snap|tar\.gz|tgz|tar\.xz|zip)$/i.test(name)
  return false
}

function artifactRank (platform, name) {
  const order = {
    macos: [/\.dmg$/i, /\.pkg$/i, /\.app\.zip$/i, /\.zip$/i],
    windows: [/\.exe$/i, /\.msix$/i, /\.msi$/i, /\.zip$/i],
    linux: [/\.AppImage$/i, /\.deb$/i, /\.rpm$/i, /\.snap$/i, /\.tar\.gz$/i, /\.tgz$/i, /\.tar\.xz$/i, /\.zip$/i]
  }[platform] || []
  const index = order.findIndex((pattern) => pattern.test(name))
  return index === -1 ? order.length : index
}

function githubReleaseAssetUrl (repo, tag, name) {
  return `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`
}

function printJson (result) {
  console.log(JSON.stringify(result, null, 2))
}

function printHuman (result) {
  console.log(`PearBrowser ${result.tag} native package for ${result.platform}/${result.arch}`)
  console.log(`package:  ${result.asset.name}`)
  console.log(`          ${result.asset.url}`)
  console.log(`checksum: ${result.checksum.name}`)
  console.log(`          ${result.checksum.url}`)
  if (result.release.isDraft) console.warn('warning: release is still a draft')
  if (result.release.isPrerelease) console.warn('warning: release is marked prerelease')
}