OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / scripts / generate-native-install-smoke-plan.mjs

478 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 SUPPORTED_TARGETS = [
  { label: 'macOS Apple Silicon', platform: 'macos', arch: 'arm64' },
  { label: 'macOS Intel', platform: 'macos', arch: 'x64' },
  { label: 'Windows x64', platform: 'windows', arch: 'x64' },
  { label: 'Linux x64', platform: 'linux', arch: 'x64' }
]

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 trustMode = normalizeTrustMode(args.trustMode || 'package-proof')
const sourceRef = normalizeSourceRef(args.sourceRef || 'main')

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

const report = buildSmokePlan(release, { repo, tag, version, trustMode, sourceRef })

if (args.json) printJson(report)
else printMarkdown(report)

if (!report.ok) process.exit(1)

function parseArgs (argv) {
  const parsed = {
    tag: '',
    repo: '',
    fixture: '',
    trustMode: '',
    sourceRef: '',
    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 === '--trust-mode') parsed.trustMode = requireValue(argv, ++i, arg)
    else if (arg === '--source-ref') parsed.sourceRef = 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/generate-native-install-smoke-plan.mjs [--tag v0.5.0] [--repo owner/repo] [--trust-mode package-proof|public-trust] [--source-ref main] [--json]')
  console.error('       node scripts/generate-native-install-smoke-plan.mjs --fixture release.json [--tag v0.5.0] [--trust-mode package-proof|public-trust] [--source-ref main] [--json]')
  process.exit(code)
}

function fail (message) {
  if (args.json) {
    console.log(JSON.stringify({
      ok: false,
      repo,
      tag,
      version,
      trustMode,
      sourceRef,
      targets: [],
      warnings: [],
      errors: [message]
    }, null, 2))
  } else {
    console.error(`Native install smoke plan generation 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 normalizeTrustMode (value) {
  const mode = String(value || '').toLowerCase()
  if (mode === 'package-proof' || mode === 'public-trust') return mode
  usage(2, `unsupported trust mode: ${value}`)
}

function normalizeSourceRef (value) {
  const ref = String(value || '').trim()
  if (!ref) usage(2, '--source-ref cannot be empty')
  if (!/^[A-Za-z0-9._/@+-]+$/.test(ref)) {
    usage(2, `--source-ref contains unsupported characters: ${value}`)
  }
  return ref
}

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 buildSmokePlan (release, options) {
  const errors = []
  const warnings = []
  const targets = []
  const tagName = release?.tagName || release?.tag_name || ''

  if (tagName && tagName !== options.tag) {
    errors.push(`release tagName ${tagName} does not match expected ${options.tag}`)
  }
  if (options.trustMode === 'public-trust') {
    if (release?.isDraft) errors.push('public-trust clean-install smoke requires a published release')
    if (release?.isPrerelease) errors.push('public-trust clean-install smoke requires a non-prerelease release')
  } else {
    warnings.push('package-proof clean-install smoke may include expected macOS or Windows OS trust prompts')
  }

  for (const target of SUPPORTED_TARGETS) {
    try {
      const resolved = resolveReleaseAsset(release, { ...options, ...target })
      validateTrustMode(target, resolved, options, errors)
      targets.push({
        label: target.label,
        platform: target.platform,
        arch: target.arch,
        cleanHost: cleanHostFor(target.platform),
        asset: resolved.asset,
        checksum: resolved.checksum,
        commands: commandsFor(target, resolved, options),
        evidence: evidenceFor(target.platform, options.trustMode)
      })
    } catch (err) {
      errors.push(`${target.platform}/${target.arch}: ${err && err.message ? err.message : String(err)}`)
    }
  }

  return {
    ok: errors.length === 0,
    repo: options.repo,
    tag: options.tag,
    version: options.version,
    trustMode: options.trustMode,
    sourceRef: options.sourceRef,
    runtimeSmokeScript: runtimeSmokeScriptUrl(options),
    release: {
      tagName,
      isDraft: !!release?.isDraft,
      isPrerelease: !!release?.isPrerelease,
      url: githubReleasePageUrl(options.repo, options.tag)
    },
    targets,
    warnings,
    errors
  }
}

function resolveReleaseAsset (release, options) {
  const assets = normalizeAssets(release?.assets)
  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 native artifact found for ${options.tag}`)
  }

  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 {
    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)
    }
  }
}

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 validateTrustMode (target, resolved, options, errors) {
  if (options.trustMode !== 'public-trust') return
  if (target.platform === 'macos' && !/\.dmg$/i.test(resolved.asset.name)) {
    errors.push(`public-trust clean-install smoke requires notarized macOS DMG assets, got ${resolved.asset.name}`)
  }
  if (target.platform === 'windows' && !/\.exe$/i.test(resolved.asset.name)) {
    errors.push(`public-trust clean-install smoke expects the signed Windows .exe installer, got ${resolved.asset.name}`)
  }
}

function cleanHostFor (platform) {
  if (platform === 'macos') return 'Clean macOS user profile or VM with no PearBrowser source checkout in the working directory.'
  if (platform === 'windows') return 'Clean Windows VM or user profile with no PearBrowser source checkout in the working directory.'
  if (platform === 'linux') return 'Clean Ubuntu desktop VM or user profile with no PearBrowser source checkout in the working directory.'
  return 'Clean host with no PearBrowser source checkout in the working directory.'
}

function commandsFor (target, resolved, options) {
  if (target.platform === 'macos') return macosCommands(resolved, options)
  if (target.platform === 'windows') return windowsCommands(resolved, options)
  if (target.platform === 'linux') return linuxCommands(resolved, options)
  return []
}

function macosCommands (resolved, options) {
  const asset = shellQuote(resolved.asset.name)
  const checksum = shellQuote(resolved.checksum.name)
  const commands = [
    `curl -L -o ${asset} ${shellQuote(resolved.asset.url)}`,
    `curl -L -o ${checksum} ${shellQuote(resolved.checksum.url)}`,
    `shasum -a 256 -c ${checksum}`
  ]

  if (/\.dmg$/i.test(resolved.asset.name)) {
    commands.push(
      `hdiutil attach ${asset}`,
      'cp -R /Volumes/PearBrowser/PearBrowser.app /Applications/',
      'hdiutil detach /Volumes/PearBrowser',
      'codesign --verify --deep --strict --verbose=2 /Applications/PearBrowser.app'
    )
    if (options.trustMode === 'public-trust') {
      commands.push(
        'xcrun stapler validate /Applications/PearBrowser.app',
        'spctl --assess --type execute --verbose /Applications/PearBrowser.app'
      )
    }
    commands.push('open /Applications/PearBrowser.app')
    commands.push(...runtimeSmokeCommands('sh', options))
    return commands
  }

  commands.push(
    'rm -rf PearBrowser-install',
    'mkdir PearBrowser-install',
    `ditto -x -k ${asset} PearBrowser-install`,
    'cp -R PearBrowser-install/PearBrowser.app /Applications/',
    'codesign --verify --deep --strict --verbose=2 /Applications/PearBrowser.app',
    'open /Applications/PearBrowser.app'
  )
  commands.push(...runtimeSmokeCommands('sh', options))
  return commands
}

function windowsCommands (resolved, options) {
  const asset = powershellString(resolved.asset.name)
  const checksum = powershellString(resolved.checksum.name)
  const assetUrl = powershellString(resolved.asset.url)
  const checksumUrl = powershellString(resolved.checksum.url)
  const commands = [
    `Invoke-WebRequest -Uri ${assetUrl} -OutFile ${asset}`,
    `Invoke-WebRequest -Uri ${checksumUrl} -OutFile ${checksum}`,
    `$expected = (Get-Content .\\${resolved.checksum.name}).Split(' ')[0].ToUpperInvariant()`,
    `$actual = (Get-FileHash .\\${resolved.asset.name} -Algorithm SHA256).Hash`,
    'if ($actual -ne $expected) { throw "SHA256 mismatch" }',
    `Get-AuthenticodeSignature .\\${resolved.asset.name} | Format-List`
  ]

  if (/\.msix$/i.test(resolved.asset.name)) {
    commands.push(`Add-AppxPackage .\\${resolved.asset.name}`)
  } else {
    commands.push(`Start-Process .\\${resolved.asset.name} -Wait`)
  }
  commands.push('# Launch PearBrowser from the Start menu, then run the diagnostic smoke below.')
  commands.push(...runtimeSmokeCommands('powershell', options))
  return commands
}

function linuxCommands (resolved, options) {
  const asset = shellQuote(resolved.asset.name)
  const checksum = shellQuote(resolved.checksum.name)
  const commands = [
    `curl -L -o ${asset} ${shellQuote(resolved.asset.url)}`,
    `curl -L -o ${checksum} ${shellQuote(resolved.checksum.url)}`,
    `sha256sum -c ${checksum}`
  ]

  if (/\.AppImage$/i.test(resolved.asset.name)) {
    commands.push(
      `chmod +x ${asset}`,
      `./${resolved.asset.name}`,
      ...runtimeSmokeCommands('sh', options)
    )
  } else {
    commands.push('Install the package with the platform package manager, then launch PearBrowser from the desktop environment.')
    commands.push(...runtimeSmokeCommands('sh', options))
  }
  return commands
}

function runtimeSmokeCommands (shell, options) {
  const url = runtimeSmokeScriptUrl(options)
  if (shell === 'powershell') {
    return [
      `Invoke-WebRequest -Uri ${powershellString(url)} -OutFile 'pearbrowser-runtime-rpc-smoke.mjs'`,
      'node .\\pearbrowser-runtime-rpc-smoke.mjs --timeout 20000 --max-storage-percent 100 --json'
    ]
  }
  return [
    `curl -L -o pearbrowser-runtime-rpc-smoke.mjs ${shellQuote(url)}`,
    'node pearbrowser-runtime-rpc-smoke.mjs --timeout 20000 --max-storage-percent 100 --json'
  ]
}

function runtimeSmokeScriptUrl (options) {
  return `https://raw.githubusercontent.com/${options.repo}/${options.sourceRef}/scripts/runtime-rpc-smoke.mjs`
}

function evidenceFor (platform, trustMode) {
  const common = [
    'OS version and architecture',
    'Package filename and SHA-256 verification output',
    'Screenshot or screen recording of the first PearBrowser window',
    'runtime-rpc-smoke JSON output showing DHT/proxy/relay readiness',
    'Confirmation that the test host did not use a source checkout'
  ]
  if (platform === 'macos') {
    common.push('codesign verification output')
    if (trustMode === 'public-trust') common.push('stapler and spctl assessment output')
    else common.push('Any expected Gatekeeper prompt or trust warning text')
  } else if (platform === 'windows') {
    common.push('Get-AuthenticodeSignature output')
    if (trustMode !== 'public-trust') common.push('Any expected SmartScreen or unsigned-installer warning text')
  } else if (platform === 'linux') {
    common.push('Terminal output showing the AppImage starts on the clean desktop session')
  }
  return common
}

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

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

function shellQuote (value) {
  return `'${String(value).replace(/'/g, "'\\''")}'`
}

function powershellString (value) {
  return `'${String(value).replace(/'/g, "''")}'`
}

function markdownLink (label, url) {
  return `[${label}](${url})`
}

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

function printMarkdown (report) {
  if (!report.ok) {
    console.error(`Native install smoke plan generation failed for ${report.tag}:`)
    for (const error of report.errors) console.error(`- ${error}`)
    for (const warning of report.warnings) console.error(`warning: ${warning}`)
    return
  }

  console.log('## Native Clean-Install Smoke Plan')
  console.log()
  console.log(`Release: ${markdownLink(report.tag, report.release.url)}`)
  console.log(`Trust mode: ${report.trustMode}`)
  console.log(`Smoke helper source: ${markdownLink(report.sourceRef, report.runtimeSmokeScript)}`)
  for (const warning of report.warnings) console.log(`Warning: ${warning}`)
  console.log()
  console.log('Run each target on a clean host or VM. Record the evidence bullets back into the release smoke evidence log before announcement.')
  console.log()

  for (const target of report.targets) {
    console.log(`### ${target.label}`)
    console.log()
    console.log(`Clean host: ${target.cleanHost}`)
    console.log()
    console.log(`Package: ${markdownLink(target.asset.name, target.asset.url)}`)
    console.log(`SHA-256 sidecar: ${markdownLink(target.checksum.name, target.checksum.url)}`)
    console.log()
    console.log('Commands:')
    console.log()
    console.log(codeFence(target.platform))
    for (const command of target.commands) console.log(command)
    console.log('```')
    console.log()
    console.log('Evidence to record:')
    for (const item of target.evidence) console.log(`- ${item}`)
    console.log()
  }
}

function codeFence (platform) {
  if (platform === 'windows') return '```powershell'
  return '```sh'
}