OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / scripts / generate-public-trust-operator-report.mjs

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

import { spawnSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'

const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url))
const DEFAULT_REPO = 'bigdestiny2/pearbrowser-desktop'

const args = parseArgs(process.argv.slice(2))
const tag = normalizeTag(args.tag || `v${pkg.version}`)
const repo = args.repo || process.env.GH_REPO || DEFAULT_REPO
const sourceRef = normalizeSourceRef(args.sourceRef || 'main')
const format = normalizeFormat(args.format || 'markdown')
const readiness = loadReadiness(args, { tag, repo, sourceRef })
const report = buildReport(readiness, { tag, repo, sourceRef, evidenceFile: args.evidenceFile })

if (format === 'json') printJson(report)
else printMarkdown(report)

process.exit(report.ok ? 0 : 1)

function loadReadiness (args, defaults) {
  if (args.readinessFile) {
    return parseJson(readFileSync(args.readinessFile, 'utf8'), args.readinessFile)
  }

  const readinessArgs = [
    '--tag',
    defaults.tag,
    '--repo',
    defaults.repo,
    '--source-ref',
    defaults.sourceRef,
    '--json',
    ...(args.fixture ? ['--fixture', args.fixture] : []),
    ...(args.evidenceFile ? ['--evidence-file', args.evidenceFile] : []),
    ...(args.signingSecretSource ? ['--signing-secret-source', args.signingSecretSource] : []),
    ...(args.signingRepo ? ['--signing-repo', args.signingRepo] : []),
    ...(args.signingGithubSecretsFile ? ['--signing-github-secrets-file', args.signingGithubSecretsFile] : [])
  ]
  const result = spawnSync(process.execPath, [
    join(REPO_ROOT, 'scripts/check-public-trust-readiness.mjs'),
    ...readinessArgs
  ], {
    cwd: REPO_ROOT,
    encoding: 'utf8',
    env: process.env,
    maxBuffer: 1024 * 1024 * 50
  })
  if (!String(result.stdout || '').trim()) {
    return {
      ok: false,
      repo: defaults.repo,
      tag: defaults.tag,
      sourceRef: defaults.sourceRef,
      checks: [],
      blockers: [{ check: 'readiness', message: trimForReport(result.stderr) || 'readiness command failed without JSON output' }],
      warnings: [],
      readinessExitCode: result.status,
      readinessCommand: ['node', 'scripts/check-public-trust-readiness.mjs', ...readinessArgs].join(' ')
    }
  }
  const readiness = parseJson(result.stdout, 'check-public-trust-readiness stdout')
  readiness.readinessExitCode = result.status
  readiness.readinessCommand = ['node', 'scripts/check-public-trust-readiness.mjs', ...readinessArgs].join(' ')
  return readiness
}

function parseJson (text, source) {
  if (!String(text || '').trim()) throw new Error(`${source} is empty`)
  try {
    return JSON.parse(String(text))
  } catch (err) {
    throw new Error(`could not parse ${source} as JSON: ${err.message}`)
  }
}

function buildReport (readiness, defaults) {
  const normalized = readiness && typeof readiness === 'object' ? readiness : {}
  const repo = normalized.repo || defaults.repo
  const tag = normalized.tag || defaults.tag
  const sourceRef = normalized.sourceRef || defaults.sourceRef
  const evidenceFile = normalized.evidenceFile || defaults.evidenceFile || ''
  const checks = Array.isArray(normalized.checks) ? normalized.checks.map(normalizeCheck) : []
  const blockers = Array.isArray(normalized.blockers)
    ? normalized.blockers.map(normalizeBlocker)
    : checks.flatMap((check) => check.blockers.map((message) => ({ check: check.id, message })))
  const warnings = Array.isArray(normalized.warnings)
    ? normalized.warnings.map(normalizeBlocker)
    : checks.flatMap((check) => check.warnings.map((message) => ({ check: check.id, message })))
  const blockerGroups = groupBlockers(blockers)
  const nextCommands = nextCommandsFor({ repo, tag, sourceRef, evidenceFile })

  return {
    ok: blockers.length === 0 && normalized.ok !== false,
    repo,
    tag,
    sourceRef,
    evidenceFile,
    mode: normalized.mode || 'public-trust',
    checks,
    blockerGroups,
    warnings,
    nextCommands,
    readinessExitCode: normalized.readinessExitCode ?? null,
    readinessCommand: normalized.readinessCommand || ''
  }
}

function normalizeCheck (check) {
  return {
    id: String(check?.id || 'unknown'),
    label: String(check?.label || checkLabel(check?.id || 'unknown')),
    status: String(check?.status || (check?.ok ? 'pass' : 'block')),
    ok: Boolean(check?.ok),
    summary: String(check?.summary || ''),
    command: String(check?.command || ''),
    blockers: Array.isArray(check?.blockers) ? check.blockers.map(String) : [],
    warnings: Array.isArray(check?.warnings) ? check.warnings.map(String) : []
  }
}

function normalizeBlocker (blocker) {
  if (typeof blocker === 'string') return { check: 'unknown', message: blocker }
  return {
    check: String(blocker?.check || 'unknown'),
    message: String(blocker?.message || blocker?.reason || '')
  }
}

function groupBlockers (blockers) {
  const groups = new Map()
  for (const blocker of blockers) {
    const id = blocker.check || 'unknown'
    if (!groups.has(id)) {
      groups.set(id, {
        id,
        label: checkLabel(id),
        blockers: []
      })
    }
    groups.get(id).blockers.push(blocker.message)
  }
  return [...groups.values()]
}

function nextCommandsFor ({ repo, tag, sourceRef, evidenceFile }) {
  const evidenceArgs = evidenceFile ? ` -- --file ${shellQuote(evidenceFile)}` : ''
  return [
    {
      id: 'credential-handoff',
      label: 'Generate signing secret handoff',
      command: `npm run -s generate:native-signing-secret-plan -- --repo ${shellQuote(repo)} --tag ${shellQuote(tag)} --source-ref ${shellQuote(sourceRef)}`
    },
    {
      id: 'verify-secret-names',
      label: 'Verify GitHub Actions secret names',
      command: `npm run check:native-signing -- --require-public-trust --secret-source github --repo ${shellQuote(repo)}`
    },
    {
      id: 'dispatch-public-trust-workflow',
      label: 'Run Desktop Native Release in public-trust mode',
      command: `gh workflow run desktop-native-release.yml --repo ${shellQuote(repo)} --ref main -f tag=${shellQuote(tag)} -f source_ref=${shellQuote(sourceRef)} -f release_mode=public-trust`
    },
    {
      id: 'rerun-readiness',
      label: 'Rerun the public-trust readiness gate',
      command: `npm run check:public-trust-readiness -- --tag ${shellQuote(tag)} --repo ${shellQuote(repo)} --source-ref ${shellQuote(sourceRef)} --signing-secret-source github`
    },
    {
      id: 'regenerate-install-guide',
      label: 'Regenerate public-trust install guidance after assets pass',
      command: `npm run -s generate:native-install-guide -- --tag ${shellQuote(tag)} --repo ${shellQuote(repo)} --trust-mode public-trust`
    },
    {
      id: 'generate-clean-install-smoke-plan',
      label: 'Generate clean-machine install smoke plan',
      command: `npm run -s generate:native-install-smoke-plan -- --tag ${shellQuote(tag)} --repo ${shellQuote(repo)} --trust-mode public-trust --source-ref ${shellQuote(sourceRef)}`
    },
    {
      id: 'generate-package-manager-drafts',
      label: 'Generate package-manager drafts from public-trust assets',
      command: `npm run generate:package-manager-manifests -- --tag ${shellQuote(tag)} --repo ${shellQuote(repo)}`
    },
    {
      id: 'generate-release-evidence-handoff',
      label: 'Generate release evidence handoff',
      command: `npm run -s generate:release-evidence-handoff${evidenceArgs}`
    },
    {
      id: 'verify-evidence-log',
      label: 'Verify the operator evidence log',
      command: `npm run check:release-evidence${evidenceArgs}`
    }
  ]
}

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

function printMarkdown (report) {
  console.log('# Public-Trust Release Operator Report')
  console.log('')
  console.log(`Repository: \`${report.repo}\``)
  console.log(`Release tag: \`${report.tag}\``)
  console.log(`Source ref: \`${report.sourceRef}\``)
  console.log(`Status: \`${report.ok ? 'READY' : 'BLOCKED'}\``)
  console.log('')

  console.log('## Gate Summary')
  console.log('')
  if (!report.checks.length) {
    console.log('No readiness checks were reported.')
  } else {
    console.log('| Gate | Status | Summary |')
    console.log('| --- | --- | --- |')
    for (const check of report.checks) {
      console.log(`| ${escapeTableCell(check.label)} | ${escapeTableCell(check.status.toUpperCase())} | ${escapeTableCell(check.summary || '-')} |`)
    }
  }
  console.log('')

  console.log('## Blocking Work')
  console.log('')
  if (!report.blockerGroups.length) {
    console.log('No blockers remain. Treat this as ready only after the release evidence log and final decision are current.')
  } else {
    for (const group of report.blockerGroups) {
      console.log(`### ${group.label}`)
      console.log('')
      for (const blocker of group.blockers) console.log(`- [ ] ${blocker}`)
      console.log('')
    }
  }

  if (report.warnings.length) {
    console.log('## Warnings And Deferrals')
    console.log('')
    for (const warning of report.warnings) console.log(`- ${checkLabel(warning.check)}: ${warning.message}`)
    console.log('')
  }

  console.log('## Next Commands')
  console.log('')
  for (const item of report.nextCommands) {
    console.log(`### ${item.label}`)
    console.log('')
    console.log('```sh')
    console.log(item.command)
    console.log('```')
    console.log('')
  }
}

function checkLabel (id) {
  const labels = {
    'native-signing': 'Signing Credentials',
    'native-release-assets': 'Public-Trust Release Assets',
    'native-downloads': 'Native Package Downloads',
    'linux-appimage-metadata': 'Linux AppImage Metadata',
    'native-install-smoke-plan': 'Clean-Machine Install Smoke',
    'package-manager-manifests': 'Package-Manager Drafts',
    'release-evidence': 'Operator Evidence',
    readiness: 'Readiness Command',
    unknown: 'Other'
  }
  return labels[id] || id
}

function escapeTableCell (value) {
  return String(value || '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')
}

function trimForReport (value) {
  const text = String(value || '').trim().replace(/\s+/g, ' ')
  if (text.length <= 400) return text
  return `${text.slice(0, 397)}...`
}

function shellQuote (value) {
  const text = String(value || '')
  if (/^[A-Za-z0-9_./:@+-]+$/.test(text)) return text
  return `'${text.replace(/'/g, "'\\''")}'`
}

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 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 normalizeFormat (value) {
  const normalized = String(value || '').toLowerCase()
  if (normalized === 'markdown' || normalized === 'md') return 'markdown'
  if (normalized === 'json') return 'json'
  usage(2, `unknown format: ${value}`)
}

function parseArgs (argv) {
  const parsed = {
    tag: '',
    repo: '',
    sourceRef: '',
    readinessFile: '',
    fixture: '',
    evidenceFile: '',
    signingSecretSource: '',
    signingRepo: '',
    signingGithubSecretsFile: '',
    format: ''
  }
  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 === '--source-ref') parsed.sourceRef = requireValue(argv, ++i, arg)
    else if (arg === '--readiness-file') parsed.readinessFile = requireValue(argv, ++i, arg)
    else if (arg === '--fixture') parsed.fixture = requireValue(argv, ++i, arg)
    else if (arg === '--evidence-file') parsed.evidenceFile = requireValue(argv, ++i, arg)
    else if (arg === '--signing-secret-source') parsed.signingSecretSource = requireValue(argv, ++i, arg)
    else if (arg === '--signing-repo') parsed.signingRepo = requireValue(argv, ++i, arg)
    else if (arg === '--signing-github-secrets-file') parsed.signingGithubSecretsFile = requireValue(argv, ++i, arg)
    else if (arg === '--format') parsed.format = requireValue(argv, ++i, arg)
    else if (arg === '--json') parsed.format = 'json'
    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-public-trust-operator-report.mjs [--tag v0.5.0] [--repo owner/repo] [--source-ref main] [--readiness-file readiness.json] [--fixture release.json] [--evidence-file docs/RELEASE_SMOKE_EVIDENCE_LOG_2026-06-23.md] [--signing-secret-source env|github] [--signing-repo owner/repo] [--signing-github-secrets-file secrets.json] [--format markdown|json] [--json]')
  process.exit(code)
}