OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / check-release-distribution-env.mjs

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

import fs from 'node:fs'
import { readEnvFile } from './lib/release-env-file.mjs'

const FLEET_ROLLOUT_TIMEOUT_MIN_MS = 10 * 60 * 1000
const FLEET_ROLLOUT_TIMEOUT_MAX_MS = 4 * 60 * 60 * 1000

const usage = `
Usage:
  node scripts/check-release-distribution-env.mjs --channel <canary|stable|both|none> --prerelease <true|false> [--public-gateway-release <true|false>] [--env-file <path>] [--github-env <path>]
  node scripts/check-release-distribution-env.mjs --issue-120-repair --env-file <path>

Defaults:
  --channel defaults to both for full releases and none for prereleases when HIVERELAY_RELEASE_CHANNEL is unset.

Local candidate validation:
  --env-file reads NAME=value and NAME<<DELIM blocks so operators can validate
  multiline release values before writing them to masked GitHub Secrets.
  Candidate validation is hermetic and does not fall back to ambient shell secrets.

Issue #120 repair validation checks only the four currently malformed masked
values named in the latest issue comment: UMBREL_STORE_TOKEN,
UMBREL_OFFICIAL_PR_TOKEN, UMBREL_OFFICIAL_FORK, and STARTOS_REGISTRY_URL.
`

const args = parseArgs(process.argv.slice(2))
if (args.issue120Repair && !args.envFile) die('--issue-120-repair requires --env-file so ambient secrets cannot mask the repair candidate.')
const sourceEnv = args.envFile ? safeReadEnvFile(args.envFile) : process.env
const prerelease = args.issue120Repair ? false : readBoolean(args.prerelease ?? sourceEnv.HIVERELAY_RELEASE_PRERELEASE, '--prerelease')
const publicGatewayRelease = args.issue120Repair
  ? false
  : readBoolean(args.publicGatewayRelease ?? sourceEnv.HIVERELAY_PUBLIC_GATEWAY_RELEASE_ENABLED, '--public-gateway-release')
const channel = args.issue120Repair ? 'both' : (args.channel || sourceEnv.HIVERELAY_RELEASE_CHANNEL || (prerelease ? 'none' : 'both'))
const githubEnv = args.githubEnv || process.env.GITHUB_ENV || ''
const result = args.issue120Repair
  ? checkIssue120MaskedValueRepair({ env: sourceEnv })
  : checkReleaseDistributionEnv({ channel, prerelease, publicGatewayRelease, env: sourceEnv })

appendGithubEnv(githubEnv, result.envUpdates)

if (!result.ok) {
  console.error(result.failureHeading || 'Release distribution preflight failed:')
  for (const item of result.missing) console.error(`- ${item}`)
  console.error('')
  console.error(result.repairPath || formatRepairPath({ channel, prerelease }))
  process.exit(1)
}

if (result.skipped) {
  console.log('Distribution credential preflight skipped for prerelease.')
} else if (result.successMessage) {
  console.log(result.successMessage)
} else {
  console.log('Stable release distribution preflight passed.')
}

function checkReleaseDistributionEnv ({ channel, prerelease, publicGatewayRelease, env }) {
  if (!['canary', 'stable', 'both', 'none'].includes(channel)) {
    return {
      ok: false,
      skipped: false,
      missing: [`invalid channel "${channel}"`],
      envUpdates: {
        HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS: 'failed',
        HIVERELAY_RELEASE_SURFACES_STATUS: 'blocked'
      }
    }
  }

  if (prerelease) {
    if (channel !== 'none') {
      return {
        ok: false,
        skipped: false,
        missing: [`pre-release channel must be none; got "${channel}"`],
        envUpdates: {
          HIVERELAY_RELEASE_EFFECTIVE_CHANNEL: channel,
          HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS: 'failed',
          HIVERELAY_RELEASE_SURFACES_STATUS: 'blocked',
          HIVERELAY_FLEET_ROLLOUT_STATUS: 'blocked-prerelease-promotion'
        }
      }
    }
    return {
      ok: true,
      skipped: true,
      missing: [],
      envUpdates: {
        HIVERELAY_RELEASE_EFFECTIVE_CHANNEL: channel,
        HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS: 'skipped'
      }
    }
  }

  const missing = []
  const envUpdates = {
    HIVERELAY_RELEASE_EFFECTIVE_CHANNEL: channel,
    HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS: 'passed'
  }

  const requireSecret = (name, statusNames, opts = {}) => {
    const value = String(env[name] || '')
    if (!value.trim()) {
      missing.push(name)
      for (const statusName of statusNames) envUpdates[statusName] = 'missing-secret'
      return
    }
    if (!opts.validate || opts.validate(value)) return
    missing.push(`${name} ${opts.message}`)
    for (const statusName of statusNames) envUpdates[statusName] = opts.status || 'invalid-secret'
  }

  const requireGitHubToken = (name, statusNames) => requireSecret(name, statusNames, {
    validate: isGitHubToken,
    status: 'invalid-token',
    message: 'must be a GitHub token without whitespace or control characters'
  })

  const requirePrivateKey = (name, statusNames) => requireSecret(name, statusNames, {
    validate: isPrivateKeyBlock,
    status: 'invalid-secret',
    message: 'must be a private key block'
  })

  const requireOptionalFleetTimeout = () => {
    const value = String(env.FLEET_ROLLOUT_TIMEOUT_MS || '')
    if (!value) return
    if (!isIntegerInRange(value, FLEET_ROLLOUT_TIMEOUT_MIN_MS, FLEET_ROLLOUT_TIMEOUT_MAX_MS)) {
      missing.push(`FLEET_ROLLOUT_TIMEOUT_MS must be an integer between ${FLEET_ROLLOUT_TIMEOUT_MIN_MS} and ${FLEET_ROLLOUT_TIMEOUT_MAX_MS} milliseconds without whitespace or control characters`)
      envUpdates.HIVERELAY_FLEET_ROLLOUT_STATUS = 'invalid-timeout'
    }
  }

  if (publicGatewayRelease && channel !== 'none') {
    missing.push('public gateway release channel must be none')
    envUpdates.HIVERELAY_FLEET_ROLLOUT_STATUS = 'invalid-gateway-channel'
  } else if (channel === 'none' && !publicGatewayRelease) {
    missing.push('release channel must be canary, stable, or both')
    envUpdates.HIVERELAY_FLEET_ROLLOUT_STATUS = 'missing-channel'
  } else if (publicGatewayRelease) {
    envUpdates.HIVERELAY_FLEET_ROLLOUT_STATUS = 'deferred-gateway-canary-gated'
  } else {
    requirePrivateKey('FLEET_SSH_PRIVATE_KEY', ['HIVERELAY_FLEET_ROLLOUT_STATUS'])
    requireOptionalFleetTimeout()
  }

  requireGitHubToken('UMBREL_STORE_TOKEN', [
    'HIVERELAY_UMBREL_COMMUNITY_STORE_VALIDATE_STATUS',
    'HIVERELAY_UMBREL_COMMUNITY_STORE_STATUS'
  ])
  requireGitHubToken('UMBREL_OFFICIAL_PR_TOKEN', ['HIVERELAY_UMBREL_OFFICIAL_PR_STATUS'])
  requireGitHubToken('ECOSYSTEM_CONSUMER_TOKEN', ['HIVERELAY_ECOSYSTEM_CONSUMER_STATUS'])
  requireSecret('UMBREL_OFFICIAL_FORK', ['HIVERELAY_UMBREL_OFFICIAL_PR_STATUS'])
  if (String(env.UMBREL_OFFICIAL_FORK || '').trim()) {
    const fork = normalizeOfficialUmbrelForkSlug(env.UMBREL_OFFICIAL_FORK)
    if (fork && isOfficialUmbrelForkSlug(fork)) envUpdates.UMBREL_OFFICIAL_FORK = fork
    else {
      missing.push('UMBREL_OFFICIAL_FORK must be a GitHub owner/umbrel-apps fork slug with a normal owner name and must not be getumbrel/umbrel-apps')
      envUpdates.HIVERELAY_UMBREL_OFFICIAL_PR_STATUS = 'invalid-fork'
    }
  }
  requireSecret('NPM_TOKEN', ['HIVERELAY_NPM_PUBLISH_STATUS'], {
    validate: isNpmToken,
    status: 'invalid-token',
    message: 'must be an npm automation token without whitespace or control characters'
  })
  requirePrivateKey('STARTOS_DEVELOPER_KEY_PEM', ['HIVERELAY_STARTOS_REGISTRY_STATUS'])
  requireSecret('STARTOS_REGISTRY_URL', ['HIVERELAY_STARTOS_REGISTRY_STATUS'])
  if (String(env.STARTOS_REGISTRY_URL || '').trim()) {
    const registryUrl = normalizePublicHttpsUrl(env.STARTOS_REGISTRY_URL)
    if (registryUrl && isPublicHttpsUrl(registryUrl)) envUpdates.STARTOS_REGISTRY_URL = registryUrl
    else {
      missing.push('STARTOS_REGISTRY_URL must be a public https URL without embedded credentials, query strings, fragments, or reserved/local hostnames')
      envUpdates.HIVERELAY_STARTOS_REGISTRY_STATUS = 'invalid-registry-url'
    }
  }

  if (missing.length > 0) {
    envUpdates.HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS = 'failed'
    envUpdates.HIVERELAY_RELEASE_SURFACES_STATUS = 'blocked'
  }

  return {
    ok: missing.length === 0,
    skipped: false,
    missing,
    envUpdates
  }
}

function checkIssue120MaskedValueRepair ({ env }) {
  const missing = []
  const envUpdates = {
    HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS: 'issue-120-repair-candidate-passed'
  }

  const requireSecret = (name, statusNames, opts = {}) => {
    const value = String(env[name] || '')
    if (!value.trim()) {
      missing.push(name)
      for (const statusName of statusNames) envUpdates[statusName] = 'missing-secret'
      return null
    }
    if (!opts.validate) return value
    const normalized = opts.validate(value)
    if (normalized) return normalized
    missing.push(`${name} ${opts.message}`)
    for (const statusName of statusNames) envUpdates[statusName] = opts.status || 'invalid-secret'
    return null
  }

  requireSecret('UMBREL_STORE_TOKEN', [
    'HIVERELAY_UMBREL_COMMUNITY_STORE_VALIDATE_STATUS',
    'HIVERELAY_UMBREL_COMMUNITY_STORE_STATUS'
  ], {
    validate: value => isGitHubToken(value) ? value : null,
    status: 'invalid-token',
    message: 'must be a GitHub token without whitespace or control characters'
  })
  requireSecret('UMBREL_OFFICIAL_PR_TOKEN', ['HIVERELAY_UMBREL_OFFICIAL_PR_STATUS'], {
    validate: value => isGitHubToken(value) ? value : null,
    status: 'invalid-token',
    message: 'must be a GitHub token without whitespace or control characters'
  })
  const fork = requireSecret('UMBREL_OFFICIAL_FORK', ['HIVERELAY_UMBREL_OFFICIAL_PR_STATUS'], {
    validate: normalizeOfficialUmbrelForkSlug,
    status: 'invalid-fork',
    message: 'must be a GitHub owner/umbrel-apps fork slug with a normal owner name and must not be getumbrel/umbrel-apps'
  })
  if (fork && isOfficialUmbrelForkSlug(fork)) envUpdates.UMBREL_OFFICIAL_FORK = fork

  const registryUrl = requireSecret('STARTOS_REGISTRY_URL', ['HIVERELAY_STARTOS_REGISTRY_STATUS'], {
    validate: normalizePublicHttpsUrl,
    status: 'invalid-registry-url',
    message: 'must be a public https URL without embedded credentials, query strings, fragments, or reserved/local hostnames'
  })
  if (registryUrl && isPublicHttpsUrl(registryUrl)) envUpdates.STARTOS_REGISTRY_URL = registryUrl

  if (missing.length > 0) {
    envUpdates.HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS = 'issue-120-repair-candidate-failed'
    envUpdates.HIVERELAY_RELEASE_SURFACES_STATUS = 'blocked'
  }

  return {
    ok: missing.length === 0,
    skipped: false,
    missing,
    envUpdates,
    failureHeading: 'Issue #120 masked-value repair candidate failed:',
    successMessage: 'Issue #120 masked-value repair candidate passed.',
    repairPath: formatIssue120RepairPath()
  }
}

function isGitHubToken (value) {
  return typeof value === 'string' &&
    value.trim() === value &&
    !hasControlChars(value) &&
    /^(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})$/.test(value)
}

function isNpmToken (value) {
  return typeof value === 'string' &&
    value.trim() === value &&
    !hasControlChars(value) &&
    /^npm_[A-Za-z0-9._~-]{20,}$/.test(value)
}

function isPrivateKeyBlock (value) {
  if (typeof value !== 'string' || value.trim() !== value) return false
  if (hasPrivateKeyControlChars(value)) return false
  const normalized = value.replace(/\r\n/g, '\n')
  const match = /^-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----\n[\s\S]+\n-----END \1-----$/.exec(normalized)
  return Boolean(match)
}

function isIntegerInRange (value, min, max) {
  if (typeof value !== 'string' || value.trim() !== value) return false
  if (hasControlChars(value)) return false
  if (!/^[1-9][0-9]*$/.test(value)) return false
  const n = Number(value)
  return Number.isSafeInteger(n) && n >= min && n <= max
}

function hasPrivateKeyControlChars (value) {
  for (let i = 0; i < value.length; i++) {
    const code = value.charCodeAt(i)
    if (code === 10 || code === 13) continue
    if (code <= 31 || code === 127) return true
  }
  return false
}

function parseArgs (argv) {
  const out = {}
  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i]
    if (arg === '--help' || arg === '-h') {
      console.log(usage.trim())
      process.exit(0)
    }
    if (arg === '--channel') {
      out.channel = readValue(argv, ++i, arg)
      continue
    }
    if (arg === '--prerelease') {
      out.prerelease = readValue(argv, ++i, arg)
      continue
    }
    if (arg === '--public-gateway-release') {
      out.publicGatewayRelease = readValue(argv, ++i, arg)
      continue
    }
    if (arg === '--github-env') {
      out.githubEnv = readValue(argv, ++i, arg)
      continue
    }
    if (arg === '--env-file') {
      out.envFile = readValue(argv, ++i, arg)
      continue
    }
    if (arg === '--issue-120-repair') {
      out.issue120Repair = true
      continue
    }
    die(`Unknown argument: ${arg}`)
  }
  return out
}

function safeReadEnvFile (file) {
  try {
    return readEnvFile(file)
  } catch (err) {
    die(err.message || 'Unable to read env file')
  }
}

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

function readBoolean (value, flag) {
  const normalized = String(value || 'false').trim().toLowerCase()
  if (normalized === 'true') return true
  if (normalized === 'false') return false
  die(`Invalid ${flag} "${value}". Expected true or false.`)
}

function isPublicHttpsUrl (value) {
  return Boolean(normalizePublicHttpsUrl(value))
}

function normalizePublicHttpsUrl (value) {
  if (typeof value !== 'string' || !value) return null
  const trimmed = value.trim()
  if (!trimmed || hasControlChars(trimmed)) return null
  try {
    const url = new URL(trimmed)
    if (!(url.protocol === 'https:' &&
      !url.username &&
      !url.password &&
      !url.search &&
      !url.hash &&
      isPublicHostname(url.hostname))) {
      return null
    }
    return `${url.origin}${normalizeUrlPath(url.pathname)}`
  } catch (_) {
    return null
  }
}

function normalizeUrlPath (pathname) {
  const value = String(pathname || '')
  if (!value || value === '/') return ''
  return value.replace(/\/+$/, '')
}

function isPublicHostname (hostname) {
  const host = String(hostname || '').toLowerCase()
  if (!/^[a-z0-9.-]+$/.test(host)) return false
  if (!host.includes('.')) return false
  if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) return false

  const labels = host.split('.')
  if (labels.some(label => !label || label.length > 63 || label.startsWith('-') || label.endsWith('-'))) return false
  const tld = labels[labels.length - 1]
  if (!/^[a-z]{2,63}$/.test(tld)) return false

  const reservedHosts = new Set(['localhost', 'example.com', 'example.net', 'example.org'])
  if (reservedHosts.has(host)) return false
  const reservedSuffixes = ['.localhost', '.local', '.internal', '.test', '.example', '.invalid', '.example.com', '.example.net', '.example.org']
  return !reservedSuffixes.some(suffix => host.endsWith(suffix))
}

function hasControlChars (value) {
  for (let i = 0; i < value.length; i++) {
    const code = value.charCodeAt(i)
    if (code <= 31 || code === 127) return true
  }
  return false
}

function isOfficialUmbrelForkSlug (value) {
  return Boolean(normalizeOfficialUmbrelForkSlug(value))
}

function normalizeOfficialUmbrelForkSlug (value) {
  if (typeof value !== 'string') return null
  const trimmed = value.trim()
  if (!trimmed || hasControlChars(trimmed)) return null
  let slug = trimmed
  const httpsUrl = /^https:\/\/github\.com\/([^/\s]+)\/([^/\s?#]+)\/?$/i.exec(slug)
  if (httpsUrl) slug = `${httpsUrl[1]}/${httpsUrl[2]}`
  const sshUrl = /^git@github\.com:([^/\s]+)\/([^/\s]+)$/.exec(slug)
  if (sshUrl) slug = `${sshUrl[1]}/${sshUrl[2]}`
  slug = slug.replace(/\.git$/i, '').replace(/\/+$/, '')
  const parts = slug.split('/')
  if (parts.length !== 2) return false
  const [owner, repo] = parts
  if (!(isGitHubOwnerName(owner) &&
    owner.toLowerCase() !== 'getumbrel' &&
    repo.toLowerCase() === 'umbrel-apps')) {
    return null
  }
  return `${owner}/umbrel-apps`
}

function isGitHubOwnerName (value) {
  return /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(value)
}

function formatRepairPath ({ channel, prerelease }) {
  const repairChannel = prerelease ? 'both' : (['canary', 'stable', 'both'].includes(channel) ? channel : 'both')
  return [
    'Repair path:',
    '- Generate a local candidate file outside the repo: npm run release:write-secret-template -- --out /private/tmp/hiverelay-release-secrets.env',
    '- Replace every REPLACE_* placeholder with corrected release values.',
    `- Validate without publishing: npm run release:check-distribution-env -- --env-file /private/tmp/hiverelay-release-secrets.env --channel ${repairChannel} --prerelease false`,
    '- Apply through stdin after validation: npm run release:apply-github-secrets -- --repo bigdestiny2/P2P-Hiverelay --env-file /private/tmp/hiverelay-release-secrets.env',
    '- Re-run the side-effect-free GitHub Actions workflow release-distribution-preflight.yml with channel=both and prerelease=false before rerunning release-surfaces.yml.'
  ].join('\n')
}

function formatIssue120RepairPath () {
  return [
    'Repair path:',
    '- Generate the targeted issue #120 repair file outside the repo: npm run release:write-secret-template -- --issue-120-repair --out /private/tmp/hiverelay-release-secrets.env',
    '- Replace the four REPLACE_* placeholders with corrected release values.',
    '- Validate without publishing: npm run release:check-distribution-env -- --issue-120-repair --env-file /private/tmp/hiverelay-release-secrets.env',
    '- Dry-run targeted rotation: npm run release:apply-github-secrets -- --issue-120-repair --repo bigdestiny2/P2P-Hiverelay --env-file /private/tmp/hiverelay-release-secrets.env --dry-run',
    '- Apply targeted rotation through stdin: npm run release:apply-github-secrets -- --issue-120-repair --repo bigdestiny2/P2P-Hiverelay --env-file /private/tmp/hiverelay-release-secrets.env',
    '- Re-run the side-effect-free GitHub Actions workflow release-distribution-preflight.yml with channel=both and prerelease=false before rerunning release-surfaces.yml for v0.20.2.'
  ].join('\n')
}

function appendGithubEnv (file, updates) {
  if (!file) return
  const lines = []
  for (const [name, value] of Object.entries(updates)) {
    lines.push(formatGithubEnvLine(name, value))
  }
  fs.appendFileSync(file, lines.join('\n') + '\n')
}

function formatGithubEnvLine (name, value) {
  if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) {
    die(`Refusing to write malformed GitHub environment variable name: ${JSON.stringify(name)}`)
  }
  const stringValue = String(value)
  if (hasControlChars(stringValue)) {
    die(`Refusing to write multi-line or control-character value for ${name} to GitHub environment file`)
  }
  return `${name}=${stringValue}`
}

function die (message) {
  console.error(message)
  process.exit(1)
}