OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / check-release-promise-scope.mjs

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

import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import {
  findOverbroadReleasePromises,
  NARROW_RELEASE_PROMISE_LABEL
} from './lib/release-promise-scope.mjs'

const here = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(here, '..')

const usage = `
Usage:
  node scripts/check-release-promise-scope.mjs [--json] [--text <release-notes>] [--file <path>]

With no --text/--file, checks the public release-promise surfaces in this repo:
the prepare-release default notes and the official Umbrel PR body template.
This intentionally does not scan all technical docs; optional services can be
documented internally while the public release handoff stays scoped to
${NARROW_RELEASE_PROMISE_LABEL}.
`

function main () {
  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())
    return
  }

  const report = checkReleasePromiseScope({
    root: path.resolve(args.root || repoRoot),
    text: args.text,
    file: args.file
  })

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

  if (report.status !== 'pass') process.exit(1)
}

export function checkReleasePromiseScope ({ root = repoRoot, text = null, file = null } = {}) {
  const targets = []
  if (text != null) {
    targets.push({ id: 'inline', label: 'inline release text', text: String(text) })
  }
  if (file) {
    const abs = path.resolve(root, file)
    targets.push({ id: slash(path.relative(root, abs)), label: 'release text file', text: fs.readFileSync(abs, 'utf8') })
  }
  if (targets.length === 0) targets.push(...defaultTargets(root))

  const items = targets.map(target => {
    const findings = findOverbroadReleasePromises(target.text)
    return {
      id: target.id,
      status: findings.length === 0 ? 'pass' : 'fail',
      label: target.label,
      findings
    }
  })

  const failed = items.filter(item => item.status === 'fail')
  return {
    schemaVersion: 1,
    kind: 'hiverelay-release-promise-scope',
    scope: NARROW_RELEASE_PROMISE_LABEL,
    status: failed.length === 0 ? 'pass' : 'fail',
    totals: {
      pass: items.length - failed.length,
      fail: failed.length
    },
    items
  }
}

function defaultTargets (root) {
  const prepareRelease = read(path.join(root, 'scripts', 'prepare-release.mjs'))
  const workflow = read(path.join(root, '.github', 'workflows', 'release-surfaces.yml'))
  return [
    {
      id: 'prepare-release.default-notes',
      label: 'prepare-release default release notes',
      text: extractPrepareReleaseDefaultNotes(prepareRelease)
    },
    {
      id: 'release-surfaces.official-umbrel-pr-body',
      label: 'official Umbrel PR body template',
      text: extractOfficialUmbrelPrBody(workflow)
    }
  ]
}

function extractPrepareReleaseDefaultNotes (source) {
  const match = source.match(/`Blindspark \$\{tag\}:([^`]+)`/)
  return match ? `Blindspark ${match[1]}` : source
}

function extractOfficialUmbrelPrBody (source) {
  const start = source.indexOf('cat > "$pr_body" <<BODY')
  if (start === -1) return source
  const bodyStart = source.indexOf('\n', start)
  const bodyEnd = source.indexOf('\n          BODY', bodyStart)
  if (bodyStart === -1 || bodyEnd === -1) return source.slice(start)
  return source.slice(bodyStart, bodyEnd)
}

function formatReport (report) {
  const lines = [
    'HiveRelay release-promise scope check',
    `scope=${report.scope}`,
    `status=${report.status}`,
    `pass=${report.totals.pass} fail=${report.totals.fail}`
  ]
  for (const item of report.items) {
    lines.push(`${item.status.toUpperCase()} ${item.id} — ${item.label}`)
    for (const finding of item.findings) {
      lines.push(`  ${finding.name}: ${JSON.stringify(finding.match)}`)
    }
  }
  return lines.join('\n')
}

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 === '--text') {
      out.text = readValue(argv, ++i, arg)
      continue
    }
    if (arg === '--file') {
      out.file = readValue(argv, ++i, arg)
      continue
    }
    if (arg === '--root') {
      out.root = readValue(argv, ++i, 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 read (file) {
  const stat = fs.lstatSync(file)
  if (stat.isSymbolicLink()) throw new Error(`${file} must not be a symlink`)
  if (!stat.isFile()) throw new Error(`${file} must be a regular file`)
  return fs.readFileSync(file, 'utf8')
}

function slash (value) {
  return value.replace(/\\/g, '/')
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) main()