OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / check-release-blockers.mjs

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

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

const usage = `
Usage:
  node scripts/check-release-blockers.mjs --bundle-dir <release-assets-dir> --env-file <candidate-env> --npm-latest-json <npm-latest.json>
  node scripts/check-release-blockers.mjs --bundle-dir <release-assets-dir> --env-file <candidate-env> --check-npm-live

Options:
  --bundle-dir <path>       Downloaded release assets directory. Defaults to cwd.
  --env-file <path>         Local release secrets candidate for side-effect-free validation.
  --npm-latest-json <path>  Trusted offline npm latest fixture JSON.
                            If omitted, npm-latest-evidence.json from bundle-dir is accepted.
  --check-npm-live          Explicitly query npm for latest dist-tags.
  --expected-version <semver>
                            Expected HiveRelay npm latest version. Defaults to root package version.
  --channel <canary|stable|both>
                            Full-release fleet channel. Defaults to both.
  --prerelease <true|false> Defaults to false; this checker is intended for full release closure.
  --skip-git                Skip clean-worktree proof. Intended for hermetic tests only.
  --json                    Print machine-readable output.
  --out <path>              Write the public-safe JSON report, even when blocked.
`

const REQUIRED_NPM_LATEST_PACKAGES = Object.freeze([
  'p2p-hiverelay',
  'p2p-hiverelay-client',
  'p2p-hiverelay-verifier',
  'p2p-hiveservices'
])

const NPM_LATEST_EVIDENCE_FILE = 'npm-latest-evidence.json'
const MAX_EVIDENCE_JSON_BYTES = 2 * 1024 * 1024

const SECRET_PATTERNS = [
  [/-----BEGIN [A-Z ]*(?:PRIVATE|SECRET) KEY-----[\s\S]*?-----END [A-Z ]*(?:PRIVATE|SECRET) KEY-----/g, '[redacted private key block]'],
  [/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b/g, '[redacted GitHub token]'],
  [/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, '[redacted GitHub token]'],
  [/\bnpm_[A-Za-z0-9_]{20,}\b/g, '[redacted npm token]'],
  [/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/gi, 'Bearer [redacted]'],
  [/\bAuthorization\s*:\s*[^\n\r]+/gi, 'Authorization: [redacted]']
]

const EVIDENCE_FILES = Object.freeze([
  {
    id: 'evidence.release',
    label: 'release evidence',
    files: ['release-evidence.json'],
    command: 'npm run release:write-evidence -- --out release-evidence.json'
  },
  {
    id: 'evidence.image-manifest',
    label: 'GHCR image manifest evidence',
    files: ['release-image-manifest-evidence.json'],
    command: 'npm run release:check-image-manifest -- --image <ref>@sha256:<digest> --out release-image-manifest-evidence.json'
  },
  {
    id: 'evidence.image-smoke',
    label: 'digest-pinned image smoke evidence',
    files: ['release-image-smoke-evidence.json'],
    command: 'npm run release:smoke-image -- <ref>@sha256:<digest> --evidence release-image-smoke-evidence.json'
  },
  {
    id: 'evidence.umbrel-package-smoke',
    label: 'Umbrel package smoke evidence',
    files: ['umbrel-package-smoke-evidence.json'],
    command: 'npm run umbrel:smoke-package -- --image-ref <ref>@sha256:<digest> --evidence umbrel-package-smoke-evidence.json'
  },
  {
    id: 'evidence.official-umbrel-pr',
    label: 'official Umbrel PR evidence',
    files: ['official-umbrel-pr-evidence.json'],
    command: 'npm run release:write-official-umbrel-pr-evidence -- --out official-umbrel-pr-evidence.json'
  },
  {
    id: 'evidence.umbrel-runtime-review',
    label: 'real Umbrel runtime review evidence',
    files: ['umbrel-runtime-review-evidence.json'],
    command: 'npm run umbrel:write-runtime-review -- --out umbrel-runtime-review-evidence.json --release v<version> --official-pr-url https://github.com/getumbrel/umbrel-apps/pull/<number>'
  },
  {
    id: 'evidence.startos-registry',
    label: 'StartOS registry evidence',
    files: ['startos-registry-evidence.json'],
    command: 'npm run release:write-startos-registry-evidence -- --out startos-registry-evidence.json'
  },
  {
    id: 'evidence.fleet-rollout',
    label: 'fleet rollout evidence',
    files: ['fleet-rollout-evidence.json'],
    command: 'npm run fleet:check-rollout -- --target v<version> --channel both --evidence fleet-rollout-evidence.json'
  },
  {
    id: 'artifact.startos-package',
    label: 'StartOS package artifact',
    files: ['startos/blindspark.s9pk', 'blindspark.s9pk'],
    command: 'HIVERELAY_IMAGE_DIGEST=sha256:<digest> npm run startos:verify'
  }
])

const here = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(here, '..')
const rootPackage = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'))

let args
try {
  args = parseArgs(process.argv.slice(2))
} catch (err) {
  console.error(err.message)
  console.error(usage.trim())
  process.exit(1)
}

if (args.help) {
  console.log(usage.trim())
  process.exit(0)
}

if (args.npmLatestJson && args.checkNpmLive) {
  console.error('Use either --npm-latest-json or --check-npm-live, not both.')
  process.exit(1)
}

const bundleDir = path.resolve(args.bundleDir || process.cwd())
const bundleDirStatus = bundleDirectoryStatus(bundleDir)
const expectedVersion = args.expectedVersion || rootPackage.version
const channel = args.channel || 'both'
const prerelease = args.prerelease ?? false
const items = []

if (!['canary', 'stable', 'both'].includes(channel)) {
  console.error('--channel must be canary, stable, or both for full release closure.')
  process.exit(1)
}

addBundleDirCheck()
addGitCheck()
addDistributionEnvCheck()
addNpmLatestCheck()
addEvidenceFileChecks()
addReleaseVerifierChecks()

const blockers = items.filter(item => item.status === 'blocker')
const warnings = items.filter(item => item.status === 'warn')
const passes = items.filter(item => item.status === 'pass')
const report = sanitizeReport({
  schemaVersion: 1,
  generatedAt: new Date().toISOString(),
  kind: 'hiverelay-release-blocker-closure',
  scope: 'Core Availability / Blindspark full-release closure',
  status: blockers.length === 0 ? 'ready' : 'blocked',
  expectedVersion,
  channel,
  prerelease,
  bundleDir,
  totals: {
    pass: passes.length,
    warn: warnings.length,
    blocker: blockers.length
  },
  items
})

if (args.out) {
  try {
    writeReport(args.out, report)
  } catch (err) {
    console.error(`Unable to write release blocker report ${args.out}: ${err.message}`)
    process.exit(1)
  }
}

if (args.json) {
  console.log(JSON.stringify(report, null, 2))
} else {
  console.log(formatReport(report))
}

process.exit(blockers.length === 0 ? 0 : 1)

function parseArgs (argv) {
  const out = {}
  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i]
    if (arg === '--help' || arg === '-h') {
      out.help = true
      continue
    }
    if (arg === '--json') {
      out.json = true
      continue
    }
    if (arg === '--out') {
      out.out = readValue(argv, ++i, arg)
      continue
    }
    if (arg === '--skip-git') {
      out.skipGit = true
      continue
    }
    if (arg === '--check-npm-live') {
      out.checkNpmLive = true
      continue
    }
    if (arg === '--bundle-dir') {
      out.bundleDir = readValue(argv, ++i, arg)
      continue
    }
    if (arg === '--env-file') {
      out.envFile = readValue(argv, ++i, arg)
      continue
    }
    if (arg === '--npm-latest-json') {
      out.npmLatestJson = readValue(argv, ++i, arg)
      continue
    }
    if (arg === '--expected-version') {
      out.expectedVersion = readValue(argv, ++i, arg)
      continue
    }
    if (arg === '--channel') {
      out.channel = readValue(argv, ++i, arg)
      continue
    }
    if (arg === '--prerelease') {
      out.prerelease = readBoolean(readValue(argv, ++i, arg), arg)
      continue
    }
    throw new Error(`Unknown argument: ${arg}`)
  }
  return out
}

function readValue (argv, index, flag) {
  const value = argv[index]
  if (!value || value.startsWith('--')) throw new Error(`Missing value for ${flag}`)
  return value
}

function readBoolean (value, flag) {
  if (value === 'true') return true
  if (value === 'false') return false
  throw new Error(`${flag} must be true or false`)
}

function addBundleDirCheck () {
  addItem({
    id: 'bundle.dir',
    status: bundleDirStatus.ok ? 'pass' : 'blocker',
    summary: bundleDirStatus.ok
      ? 'release asset bundle directory is usable'
      : 'release asset bundle directory is not usable',
    detail: bundleDirStatus.ok ? bundleDir : `${bundleDir}: ${bundleDirStatus.error}`,
    command: 'download release assets into a regular directory and pass --bundle-dir <dir>'
  })
}

function addGitCheck () {
  if (args.skipGit) {
    addItem({
      id: 'worktree.clean',
      status: 'warn',
      summary: 'clean-worktree proof skipped',
      detail: '--skip-git is intended only for hermetic tests and local dry reports.',
      command: 'git status --porcelain=v1'
    })
    return
  }

  const res = spawnSync('git', ['status', '--porcelain=v1'], {
    cwd: repoRoot,
    encoding: 'utf8',
    timeout: 10000
  })

  if (res.error || res.status !== 0) {
    addItem({
      id: 'worktree.clean',
      status: 'blocker',
      summary: 'could not prove clean release worktree',
      detail: commandFailureDetail(res),
      command: 'git status --porcelain=v1'
    })
    return
  }

  const dirty = res.stdout.trim()
  addItem({
    id: 'worktree.clean',
    status: dirty ? 'blocker' : 'pass',
    summary: dirty ? 'release worktree has uncommitted changes' : 'release worktree is clean',
    detail: dirty ? firstLines(dirty, 12) : '',
    command: 'git status --porcelain=v1'
  })
}

function addDistributionEnvCheck () {
  if (!args.envFile) {
    addItem({
      id: 'distribution.env',
      status: 'blocker',
      summary: 'missing side-effect-free release credential proof',
      detail: 'Pass --env-file with a local candidate release secrets file before cutting a full release.',
      command: 'npm run release:check-distribution-env -- --env-file /private/tmp/hiverelay-release-secrets.env --channel both --prerelease false'
    })
    return
  }

  const envFile = path.resolve(args.envFile)
  if (!isReadableFile(envFile)) {
    addItem({
      id: 'distribution.env',
      status: 'blocker',
      summary: 'release credential candidate file is missing or unreadable',
      detail: envFile,
      command: 'npm run release:check-distribution-env -- --env-file <candidate-env> --channel both --prerelease false'
    })
    return
  }

  addCommandItem({
    id: 'distribution.env',
    passSummary: 'release credential candidate passes side-effect-free preflight',
    failSummary: 'release credential candidate is not ready',
    script: 'check-release-distribution-env.mjs',
    argv: ['--env-file', envFile, '--channel', channel, '--prerelease', String(prerelease)],
    timeout: 30000,
    command: `npm run release:check-distribution-env -- --env-file ${envFile} --channel ${channel} --prerelease ${prerelease}`
  })
}

function addNpmLatestCheck () {
  if (!args.npmLatestJson && !args.checkNpmLive) {
    const evidenceFile = path.join(bundleDir, NPM_LATEST_EVIDENCE_FILE)
    const evidenceStatus = evidenceFileStatus(evidenceFile)
    if (evidenceStatus.exists) {
      if (!evidenceStatus.ok) {
        addItem({
          id: 'npm.latest',
          status: 'blocker',
          summary: 'npm latest evidence sidecar is not usable',
          detail: evidenceStatus.error,
          command: `npm run release:check-npm-latest -- --expected-version ${expectedVersion} --out ${NPM_LATEST_EVIDENCE_FILE}`
        })
        return
      }
      const evidence = readNpmLatestEvidence(evidenceFile)
      addItem({
        id: 'npm.latest',
        status: evidence.ok ? 'pass' : 'blocker',
        summary: evidence.ok
          ? `npm latest dist-tags point at ${expectedVersion}`
          : 'npm latest evidence sidecar is not usable',
        detail: evidence.ok ? NPM_LATEST_EVIDENCE_FILE : evidence.error,
        command: `npm run release:check-npm-latest -- --expected-version ${expectedVersion} --out ${NPM_LATEST_EVIDENCE_FILE}`
      })
      return
    }

    addItem({
      id: 'npm.latest',
      status: 'blocker',
      summary: 'npm latest dist-tags have not been proven for this release',
      detail: 'Provide npm-latest-evidence.json in bundle-dir, use --npm-latest-json with trusted npm view output, or pass --check-npm-live to query npm explicitly.',
      command: 'npm run release:check-npm-latest -- --expected-version <semver> --out npm-latest-evidence.json'
    })
    return
  }

  const env = { HIVERELAY_NPM_LATEST_JSON: '' }

  if (args.npmLatestJson) {
    const fixtureFile = path.resolve(args.npmLatestJson)
    const fixture = readNpmLatestFixture(fixtureFile)
    if (!fixture.ok) {
      addItem({
        id: 'npm.latest',
        status: 'blocker',
        summary: 'npm latest fixture is not usable',
        detail: fixture.error,
        command: 'npm run release:check-npm-latest -- --expected-version <semver>'
      })
      return
    }
    env.HIVERELAY_NPM_LATEST_JSON = JSON.stringify(fixture.versions)
  }

  addCommandItem({
    id: 'npm.latest',
    passSummary: `npm latest dist-tags point at ${expectedVersion}`,
    failSummary: `npm latest dist-tags do not all point at ${expectedVersion}`,
    script: 'check-npm-latest.mjs',
    argv: ['--json', '--expected-version', expectedVersion],
    env,
    timeout: args.checkNpmLive ? 45000 : 30000,
    failureDetail: npmLatestCommandFailureDetail,
    command: args.checkNpmLive
      ? `npm run release:check-npm-latest -- --expected-version ${expectedVersion}`
      : `HIVERELAY_NPM_LATEST_JSON="$(cat ${path.resolve(args.npmLatestJson)})" npm run release:check-npm-latest -- --expected-version ${expectedVersion}`
  })
}

function readNpmLatestEvidence (file) {
  let parsed
  try {
    parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
  } catch (err) {
    return { ok: false, error: `invalid JSON in ${file}: ${err.message}` }
  }
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
    return { ok: false, error: `${file} must contain an npm latest evidence object` }
  }
  if (parsed.schemaVersion !== 1 || parsed.kind !== 'hiverelay-npm-latest-evidence') {
    return { ok: false, error: `${file} is not a HiveRelay npm latest evidence sidecar` }
  }
  if (parsed.ok !== true || parsed.status !== 'verified') {
    return { ok: false, error: `${file} does not report verified npm latest status` }
  }
  if (parsed.expectedVersion !== expectedVersion) {
    return {
      ok: false,
      error: `${file} expectedVersion is ${JSON.stringify(parsed.expectedVersion)}; expected ${expectedVersion}`
    }
  }
  const packages = Array.isArray(parsed.packages) ? parsed.packages : []
  const missingPackages = REQUIRED_NPM_LATEST_PACKAGES.filter(name => !packages.includes(name))
  if (missingPackages.length > 0) {
    return { ok: false, error: `${file} is missing npm packages: ${missingPackages.join(', ')}` }
  }
  const checks = Array.isArray(parsed.checks) ? parsed.checks : []
  const badChecks = []
  for (const name of REQUIRED_NPM_LATEST_PACKAGES) {
    const check = checks.find(row => row && row.name === name)
    if (!check) {
      badChecks.push(`${name}: missing check row`)
      continue
    }
    if (check.ok !== true || check.latest !== expectedVersion) {
      badChecks.push(`${name}: latest=${check.latest || '(missing)'}`)
    }
  }
  if (badChecks.length > 0) {
    return { ok: false, error: `${file} has stale npm latest proof: ${badChecks.join(', ')}` }
  }
  if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
    return { ok: false, error: `${file} contains npm latest errors` }
  }
  return { ok: true }
}

function addEvidenceFileChecks () {
  for (const evidence of EVIDENCE_FILES) {
    const existing = firstUsableEvidenceFile(evidence.files.map(file => path.join(bundleDir, file)))
    addItem({
      id: evidence.id,
      status: existing.ok ? 'pass' : 'blocker',
      summary: existing.ok
        ? `${evidence.label} is present`
        : existing.error
          ? `${evidence.label} is not usable`
          : `${evidence.label} is missing`,
      detail: existing.ok
        ? path.relative(bundleDir, existing.file)
        : existing.error
          ? existing.error
          : `Expected one of: ${evidence.files.join(', ')}`,
      command: evidence.command
    })
  }
}

function addReleaseVerifierChecks () {
  const missing = EVIDENCE_FILES
    .filter(evidence => !firstUsableEvidenceFile(evidence.files.map(file => path.join(bundleDir, file))).ok)
    .map(evidence => evidence.id)

  if (missing.length > 0) {
    addItem({
      id: 'release.evidence.verify',
      status: 'blocker',
      summary: 'release evidence verifier was not run because required files are missing',
      detail: missing.join(', '),
      command: 'npm run release:verify-evidence -- --bundle-dir <dir>'
    })
    addItem({
      id: 'release.handoff.verify',
      status: 'blocker',
      summary: 'review-ready handoff verifier was not run because required files are missing',
      detail: missing.join(', '),
      command: 'npm run release:verify-review-ready-handoff -- --bundle-dir <dir>'
    })
    return
  }

  addCommandItem({
    id: 'release.evidence.verify',
    passSummary: 'hash-linked release evidence bundle verifies',
    failSummary: 'hash-linked release evidence bundle failed verification',
    script: 'verify-release-evidence.mjs',
    argv: ['--bundle-dir', bundleDir],
    timeout: 120000,
    command: `npm run release:verify-evidence -- --bundle-dir ${bundleDir}`
  })

  addCommandItem({
    id: 'release.handoff.verify',
    passSummary: 'review-ready handoff bundle verifies with real Umbrel runtime evidence',
    failSummary: 'review-ready handoff bundle failed verification',
    script: 'verify-release-handoff-evidence.mjs',
    argv: ['--bundle-dir', bundleDir, '--require-umbrel-runtime-review'],
    timeout: 120000,
    command: `npm run release:verify-review-ready-handoff -- --bundle-dir ${bundleDir}`
  })
}

function readNpmLatestFixture (file) {
  if (!isReadableFile(file)) return { ok: false, error: `${file} is missing or unreadable` }
  let parsed
  try {
    parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
  } catch (err) {
    return { ok: false, error: `invalid JSON in ${file}: ${err.message}` }
  }
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
    return { ok: false, error: `${file} must contain an object keyed by npm package name` }
  }
  const missing = REQUIRED_NPM_LATEST_PACKAGES.filter(name => typeof parsed[name] !== 'string' || parsed[name] === '')
  if (missing.length > 0) {
    return {
      ok: false,
      error: `${file} is missing npm latest values for: ${missing.join(', ')}`
    }
  }
  return { ok: true, versions: parsed }
}

function addCommandItem (opts) {
  const res = spawnSync(process.execPath, [path.join('scripts', opts.script), ...opts.argv], {
    cwd: repoRoot,
    env: { ...process.env, ...(opts.env || {}) },
    encoding: 'utf8',
    timeout: opts.timeout || 30000
  })
  const failed = res.error || res.status !== 0

  addItem({
    id: opts.id,
    status: failed ? 'blocker' : 'pass',
    summary: failed ? opts.failSummary : opts.passSummary,
    detail: failed && typeof opts.failureDetail === 'function' ? opts.failureDetail(res) : commandFailureDetail(res),
    command: opts.command
  })
}

function npmLatestCommandFailureDetail (res) {
  if (!res || res.error) return commandFailureDetail(res)
  let parsed
  try {
    parsed = JSON.parse(res.stdout || '')
  } catch {
    return commandFailureDetail(res)
  }
  if (!parsed || parsed.kind !== 'hiverelay-npm-latest-evidence') return commandFailureDetail(res)

  const errors = Array.isArray(parsed.errors) ? parsed.errors.filter(Boolean) : []
  if (errors.length > 0) return errors.join('\n')

  const checks = Array.isArray(parsed.checks) ? parsed.checks : []
  const failedChecks = checks
    .filter(check => check && check.ok !== true)
    .map(check => {
      if (check.error) return check.error
      const name = check.name || '(unknown package)'
      const latest = check.latest || '(missing)'
      const expected = check.expectedVersion || parsed.expectedVersion || expectedVersion
      return `${name}: latest=${latest}; expected ${expected}`
    })

  return failedChecks.length > 0 ? failedChecks.join('\n') : commandFailureDetail(res)
}

function commandFailureDetail (res) {
  if (!res) return ''
  if (res.error) return sanitizeOutput(res.error.message)
  if (res.status === 0) return sanitizeOutput(firstLines(res.stdout || '', 4))
  return sanitizeOutput(firstLines([res.stderr, res.stdout].filter(Boolean).join('\n'), 12))
}

function addItem (item) {
  items.push({
    id: item.id,
    status: item.status,
    summary: item.summary,
    ...(item.detail ? { detail: sanitizeOutput(item.detail) } : {}),
    ...(item.command ? { command: item.command } : {})
  })
}

function firstUsableEvidenceFile (files) {
  let firstProblem = null
  for (const file of files) {
    const status = evidenceFileStatus(file)
    if (status.ok) return { ...status, file }
    if (status.exists && !firstProblem) firstProblem = { ...status, file }
  }
  if (firstProblem) return firstProblem
  return { exists: false, ok: false }
}

function evidenceFileStatus (file) {
  const rel = path.relative(bundleDir, file) || path.basename(file)
  if (!bundleDirStatus.ok) {
    return { exists: true, ok: false, error: bundleDirStatus.error }
  }
  try {
    const stat = fs.lstatSync(file)
    if (stat.isSymbolicLink()) {
      return { exists: true, ok: false, error: `${rel} must not be a symlink` }
    }
    if (!stat.isFile()) {
      return { exists: true, ok: false, error: `${rel} must be a regular file` }
    }
    if (rel.endsWith('.json') && stat.size === 0) {
      return { exists: true, ok: false, error: `${rel} must not be empty` }
    }
    if (rel.endsWith('.json') && stat.size > MAX_EVIDENCE_JSON_BYTES) {
      return { exists: true, ok: false, error: `${rel} must be ${MAX_EVIDENCE_JSON_BYTES} bytes or smaller` }
    }
    if (rel.endsWith('.json')) {
      try {
        const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
        if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
          return { exists: true, ok: false, error: `${rel} must contain a JSON object` }
        }
      } catch (err) {
        return { exists: true, ok: false, error: `${rel} must contain valid JSON: ${err.message}` }
      }
    }
    return { exists: true, ok: true }
  } catch (err) {
    if (err && err.code === 'ENOENT') return { exists: false, ok: false }
    return { exists: true, ok: false, error: `${rel} is not usable: ${err.message}` }
  }
}

function bundleDirectoryStatus (dir) {
  try {
    const stat = fs.lstatSync(dir)
    if (stat.isSymbolicLink()) return { ok: false, error: 'bundle-dir must not be a symlink' }
    if (!stat.isDirectory()) return { ok: false, error: 'bundle-dir must be a directory' }
    return { ok: true }
  } catch (err) {
    if (err && err.code === 'ENOENT') return { ok: false, error: 'bundle-dir is missing' }
    return { ok: false, error: `bundle-dir is not usable: ${err.message}` }
  }
}

function isReadableFile (file) {
  try {
    const stat = fs.statSync(file)
    return stat.isFile()
  } catch {
    return false
  }
}

function firstLines (text, limit) {
  return String(text || '')
    .split(/\r?\n/)
    .map(line => line.trimEnd())
    .filter(Boolean)
    .slice(0, limit)
    .join('\n')
}

function sanitizeOutput (text) {
  let out = String(text || '')
  for (const [pattern, replacement] of SECRET_PATTERNS) {
    out = out.replace(pattern, replacement)
  }
  return out
}

function sanitizeReport (value) {
  if (typeof value === 'string') return sanitizeOutput(value)
  if (Array.isArray(value)) return value.map(sanitizeReport)
  if (value && typeof value === 'object') {
    const out = {}
    for (const [key, child] of Object.entries(value)) out[key] = sanitizeReport(child)
    return out
  }
  return value
}

function writeReport (file, report) {
  fs.writeFileSync(path.resolve(file), JSON.stringify(report, null, 2) + '\n')
}

function formatReport (report) {
  const lines = [
    `HiveRelay release blocker closure (${report.scope})`,
    `status=${report.status} expected=${report.expectedVersion} channel=${report.channel} prerelease=${report.prerelease}`,
    `bundle=${report.bundleDir}`,
    `pass=${report.totals.pass} warn=${report.totals.warn} blocker=${report.totals.blocker}`,
    ''
  ]

  for (const item of report.items) {
    lines.push(`${item.status.toUpperCase()} ${item.id}: ${item.summary}`)
    if (item.detail) lines.push(`  ${item.detail.replace(/\n/g, '\n  ')}`)
    if (item.command) lines.push(`  proof: ${item.command}`)
  }

  if (report.status === 'ready') {
    lines.push('')
    lines.push('Ready: every tracked public-release blocker has evidence.')
  } else {
    lines.push('')
    lines.push('Blocked: do not cut the public full release until every BLOCKER row is closed.')
  }

  return lines.join('\n')
}