OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / release-distribution-env.test.js

644 lines · javascript ref:
import test from 'brittle'
import { execFile } from 'node:child_process'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'

const TEST_GITHUB_TOKEN = `ghp_${'a'.repeat(36)}`
const TEST_GITHUB_TOKEN_ALT = `gho_${'b'.repeat(36)}`
const TEST_GITHUB_TOKEN_ECOSYSTEM = `ghu_${'e'.repeat(36)}`
const TEST_NPM_TOKEN = `npm_${'c'.repeat(36)}`
const TEST_PRIVATE_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfake-fleet-key\n-----END OPENSSH PRIVATE KEY-----'
const TEST_STARTOS_PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\nfake-startos-key\n-----END PRIVATE KEY-----'

function validDistributionEnv (overrides = {}) {
  return {
    FLEET_SSH_PRIVATE_KEY: TEST_PRIVATE_KEY,
    UMBREL_STORE_TOKEN: TEST_GITHUB_TOKEN,
    UMBREL_OFFICIAL_PR_TOKEN: TEST_GITHUB_TOKEN_ALT,
    ECOSYSTEM_CONSUMER_TOKEN: TEST_GITHUB_TOKEN_ECOSYSTEM,
    UMBREL_OFFICIAL_FORK: 'bigdestiny2/umbrel-apps',
    NPM_TOKEN: TEST_NPM_TOKEN,
    STARTOS_DEVELOPER_KEY_PEM: TEST_STARTOS_PRIVATE_KEY,
    STARTOS_REGISTRY_URL: 'https://registry.start9.com',
    ...overrides
  }
}

function runCheck (argv, env = {}) {
  return new Promise((resolve) => {
    execFile(process.execPath, ['scripts/check-release-distribution-env.mjs', ...argv], {
      cwd: process.cwd(),
      env: {
        PATH: process.env.PATH || '',
        ...env
      },
      timeout: 10000
    }, (err, stdout, stderr) => {
      resolve({
        status: err && typeof err.code === 'number' ? err.code : 0,
        stdout,
        stderr
      })
    })
  })
}

async function envFile (t) {
  const dir = await mkdtemp(path.join(tmpdir(), 'hiverelay-release-env-'))
  t.teardown(async () => {
    await rm(dir, { recursive: true, force: true })
  })
  return path.join(dir, 'github-env')
}

async function candidateEnvFile (t, body) {
  const dir = await mkdtemp(path.join(tmpdir(), 'hiverelay-release-candidate-env-'))
  t.teardown(async () => {
    await rm(dir, { recursive: true, force: true })
  })
  const file = path.join(dir, 'hiverelay-release-secrets.env')
  await writeFile(file, body)
  return file
}

function validCandidateEnvBody (overrides = {}) {
  const env = validDistributionEnv(overrides)
  const lines = [
    'FLEET_SSH_PRIVATE_KEY<<FLEET_KEY',
    env.FLEET_SSH_PRIVATE_KEY,
    'FLEET_KEY',
    `UMBREL_STORE_TOKEN=${env.UMBREL_STORE_TOKEN}`,
    `UMBREL_OFFICIAL_PR_TOKEN=${env.UMBREL_OFFICIAL_PR_TOKEN}`,
    `ECOSYSTEM_CONSUMER_TOKEN=${env.ECOSYSTEM_CONSUMER_TOKEN}`,
    `UMBREL_OFFICIAL_FORK=${env.UMBREL_OFFICIAL_FORK}`,
    `NPM_TOKEN=${env.NPM_TOKEN}`,
    'STARTOS_DEVELOPER_KEY_PEM<<STARTOS_KEY',
    env.STARTOS_DEVELOPER_KEY_PEM,
    'STARTOS_KEY',
    `STARTOS_REGISTRY_URL=${env.STARTOS_REGISTRY_URL}`
  ]
  if (env.FLEET_ROLLOUT_TIMEOUT_MS) lines.push(`FLEET_ROLLOUT_TIMEOUT_MS=${env.FLEET_ROLLOUT_TIMEOUT_MS}`)
  if (env.HIVERELAY_RELEASE_CHANNEL) lines.push(`HIVERELAY_RELEASE_CHANNEL=${env.HIVERELAY_RELEASE_CHANNEL}`)
  if (env.HIVERELAY_RELEASE_PRERELEASE) lines.push(`HIVERELAY_RELEASE_PRERELEASE=${env.HIVERELAY_RELEASE_PRERELEASE}`)
  return lines.join('\n') + '\n'
}

test('release distribution env check skips prereleases', async (t) => {
  const out = await envFile(t)
  const res = await runCheck([
    '--prerelease', 'true',
    '--github-env', out
  ])

  t.is(res.status, 0)
  t.ok(res.stdout.includes('skipped for prerelease'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_RELEASE_EFFECTIVE_CHANNEL=none'))
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=skipped'))
})

test('release distribution env check rejects prerelease channel promotion', async (t) => {
  const out = await envFile(t)
  const res = await runCheck([
    '--channel', 'canary',
    '--prerelease', 'true',
    '--github-env', out
  ])

  t.is(res.status, 1)
  t.ok(res.stderr.includes('pre-release channel must be none'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_RELEASE_EFFECTIVE_CHANNEL=canary'))
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=failed'))
  t.ok(body.includes('HIVERELAY_RELEASE_SURFACES_STATUS=blocked'))
  t.ok(body.includes('HIVERELAY_FLEET_ROLLOUT_STATUS=blocked-prerelease-promotion'))
})

test('release distribution env check fails stable releases without every external credential', async (t) => {
  const out = await envFile(t)
  const res = await runCheck([
    '--channel', 'canary',
    '--prerelease', 'false',
    '--github-env', out
  ])

  t.is(res.status, 1)
  t.ok(res.stderr.includes('FLEET_SSH_PRIVATE_KEY'))
  t.ok(res.stderr.includes('UMBREL_STORE_TOKEN'))
  t.ok(res.stderr.includes('UMBREL_OFFICIAL_PR_TOKEN'))
  t.ok(res.stderr.includes('ECOSYSTEM_CONSUMER_TOKEN'))
  t.ok(res.stderr.includes('UMBREL_OFFICIAL_FORK'))
  t.ok(res.stderr.includes('NPM_TOKEN'))
  t.ok(res.stderr.includes('STARTOS_DEVELOPER_KEY_PEM'))
  t.ok(res.stderr.includes('STARTOS_REGISTRY_URL'))
  t.ok(res.stderr.includes('Repair path:'))
  t.ok(res.stderr.includes('npm run release:write-secret-template -- --out /private/tmp/hiverelay-release-secrets.env'))
  t.ok(res.stderr.includes('npm run release:check-distribution-env -- --env-file /private/tmp/hiverelay-release-secrets.env --channel canary --prerelease false'))
  t.ok(res.stderr.includes('npm run release:apply-github-secrets -- --repo bigdestiny2/P2P-Hiverelay --env-file /private/tmp/hiverelay-release-secrets.env'))
  t.ok(res.stderr.includes('release-distribution-preflight.yml'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=failed'))
  t.ok(body.includes('HIVERELAY_RELEASE_SURFACES_STATUS=blocked'))
  t.ok(body.includes('HIVERELAY_FLEET_ROLLOUT_STATUS=missing-secret'))
  t.ok(body.includes('HIVERELAY_STARTOS_REGISTRY_STATUS=missing-secret'))
  t.ok(body.includes('HIVERELAY_NPM_PUBLISH_STATUS=missing-secret'))
  t.ok(body.includes('HIVERELAY_UMBREL_OFFICIAL_PR_STATUS=missing-secret'))
  t.ok(body.includes('HIVERELAY_UMBREL_COMMUNITY_STORE_STATUS=missing-secret'))
  t.ok(body.includes('HIVERELAY_ECOSYSTEM_CONSUMER_STATUS=missing-secret'))
})

test('release distribution env check requires stable releases to promote a fleet channel', async (t) => {
  const out = await envFile(t)
  const res = await runCheck([
    '--channel', 'none',
    '--prerelease', 'false',
    '--github-env', out
  ], validDistributionEnv())

  t.is(res.status, 1)
  t.ok(res.stderr.includes('release channel must be canary, stable, or both'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_FLEET_ROLLOUT_STATUS=missing-channel'))
})

test('release distribution env check defers raw fleet rollout for validated public gateway releases', async (t) => {
  const out = await envFile(t)
  const env = validDistributionEnv()
  delete env.FLEET_SSH_PRIVATE_KEY
  const res = await runCheck([
    '--channel', 'none',
    '--prerelease', 'false',
    '--public-gateway-release', 'true',
    '--github-env', out
  ], env)

  t.is(res.status, 0)
  t.ok(res.stdout.includes('preflight passed'))
  t.absent(res.stderr.includes('FLEET_SSH_PRIVATE_KEY'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=passed'))
  t.ok(body.includes('HIVERELAY_FLEET_ROLLOUT_STATUS=deferred-gateway-canary-gated'))
})

test('release distribution env check rejects a public gateway release with a generic fleet channel', async (t) => {
  const out = await envFile(t)
  const res = await runCheck([
    '--channel', 'both',
    '--prerelease', 'false',
    '--public-gateway-release', 'true',
    '--github-env', out
  ], validDistributionEnv())

  t.is(res.status, 1)
  t.ok(res.stderr.includes('public gateway release channel must be none'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_FLEET_ROLLOUT_STATUS=invalid-gateway-channel'))
  t.ok(body.includes('HIVERELAY_RELEASE_SURFACES_STATUS=blocked'))
})

test('release distribution env check passes stable releases with every external credential', async (t) => {
  const out = await envFile(t)
  const res = await runCheck([
    '--channel', 'both',
    '--prerelease', 'false',
    '--github-env', out
  ], validDistributionEnv())

  t.is(res.status, 0)
  t.ok(res.stdout.includes('preflight passed'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=passed'))
  t.absent(body.includes('blocked'))
  t.absent(body.includes('missing-secret'))
})

test('release distribution env check validates local candidate env files before setting GitHub secrets', async (t) => {
  const out = await envFile(t)
  const candidate = await candidateEnvFile(t, validCandidateEnvBody({
    FLEET_ROLLOUT_TIMEOUT_MS: '1800000',
    HIVERELAY_RELEASE_CHANNEL: 'stable',
    HIVERELAY_RELEASE_PRERELEASE: 'false'
  }))
  const res = await runCheck([
    '--env-file', candidate,
    '--github-env', out
  ])

  t.is(res.status, 0)
  t.ok(res.stdout.includes('preflight passed'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_RELEASE_EFFECTIVE_CHANNEL=stable'))
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=passed'))
  t.absent(body.includes('missing-secret'))
  t.absent(body.includes('invalid-secret'))
})

test('release distribution env check canonicalizes issue 120 fork and registry values', async (t) => {
  const out = await envFile(t)
  const res = await runCheck([
    '--channel', 'both',
    '--prerelease', 'false',
    '--github-env', out
  ], validDistributionEnv({
    UMBREL_OFFICIAL_FORK: 'https://github.com/bigdestiny2/umbrel-apps.git',
    STARTOS_REGISTRY_URL: ' https://registry.start9.com/startos/ '
  }))

  t.is(res.status, 0)
  t.ok(res.stdout.includes('preflight passed'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('UMBREL_OFFICIAL_FORK=bigdestiny2/umbrel-apps'))
  t.ok(body.includes('STARTOS_REGISTRY_URL=https://registry.start9.com/startos'))
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=passed'))
})

test('release distribution env check validates targeted issue 120 repair candidates', async (t) => {
  const out = await envFile(t)
  const candidate = await candidateEnvFile(t, [
    `UMBREL_STORE_TOKEN=${TEST_GITHUB_TOKEN}`,
    `UMBREL_OFFICIAL_PR_TOKEN=${TEST_GITHUB_TOKEN_ALT}`,
    'UMBREL_OFFICIAL_FORK=https://github.com/bigdestiny2/umbrel-apps.git',
    'STARTOS_REGISTRY_URL= https://registry.start9.com/startos/ '
  ].join('\n') + '\n')
  const res = await runCheck([
    '--issue-120-repair',
    '--env-file', candidate,
    '--github-env', out
  ])

  t.is(res.status, 0)
  t.ok(res.stdout.includes('Issue #120 masked-value repair candidate passed.'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=issue-120-repair-candidate-passed'))
  t.ok(body.includes('UMBREL_OFFICIAL_FORK=bigdestiny2/umbrel-apps'))
  t.ok(body.includes('STARTOS_REGISTRY_URL=https://registry.start9.com/startos'))
  t.absent(body.includes('NPM_TOKEN'))
  t.absent(body.includes('FLEET_SSH_PRIVATE_KEY'))
})

test('release distribution env check rejects incomplete issue 120 repair candidates', async (t) => {
  const candidate = await candidateEnvFile(t, [
    `UMBREL_STORE_TOKEN=${TEST_GITHUB_TOKEN}`,
    `UMBREL_OFFICIAL_PR_TOKEN=${TEST_GITHUB_TOKEN_ALT}`
  ].join('\n') + '\n')
  const res = await runCheck([
    '--issue-120-repair',
    '--env-file', candidate
  ], validDistributionEnv())

  t.is(res.status, 1)
  t.ok(res.stderr.includes('Issue #120 masked-value repair candidate failed:'))
  t.ok(res.stderr.includes('UMBREL_OFFICIAL_FORK'))
  t.ok(res.stderr.includes('STARTOS_REGISTRY_URL'))
  t.absent(res.stderr.includes('FLEET_SSH_PRIVATE_KEY'))
  t.absent(res.stderr.includes('NPM_TOKEN'))
})

test('release distribution env check does not satisfy env-file candidates from ambient secrets', async (t) => {
  const candidateBody = validCandidateEnvBody()
    .split('\n')
    .filter(line => !line.startsWith('STARTOS_REGISTRY_URL='))
    .join('\n')
  const candidate = await candidateEnvFile(t, candidateBody)
  const res = await runCheck([
    '--env-file', candidate,
    '--channel', 'both',
    '--prerelease', 'false'
  ], validDistributionEnv())

  t.is(res.status, 1)
  t.ok(res.stderr.includes('STARTOS_REGISTRY_URL'))
  t.absent(res.stderr.includes('registry.start9.com'))
})

test('release distribution env check rejects malformed local candidate env files without echoing values', async (t) => {
  const secretValue = `ghp_${'s'.repeat(36)}`
  const candidate = await candidateEnvFile(t, [
    `UMBREL_STORE_TOKEN=${secretValue}`,
    `UMBREL_STORE_TOKEN=${TEST_GITHUB_TOKEN}`
  ].join('\n'))
  const res = await runCheck([
    '--env-file', candidate,
    '--channel', 'both',
    '--prerelease', 'false'
  ])

  t.is(res.status, 1)
  t.ok(res.stderr.includes('Duplicate env-file variable: UMBREL_STORE_TOKEN'))
  t.absent(res.stderr.includes(secretValue))
  t.absent(res.stdout.includes(secretValue))
})

test('release distribution env check accepts sane explicit fleet rollout timeout', async (t) => {
  const out = await envFile(t)
  const res = await runCheck([
    '--channel', 'both',
    '--prerelease', 'false',
    '--github-env', out
  ], validDistributionEnv({
    FLEET_ROLLOUT_TIMEOUT_MS: '1800000'
  }))

  t.is(res.status, 0)
  t.ok(res.stdout.includes('preflight passed'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=passed'))
  t.absent(body.includes('invalid-timeout'))
})

test('release distribution env check rejects unsafe fleet rollout timeout before SSH', async (t) => {
  const badTimeouts = [
    '1',
    '14400001',
    '1800000.5',
    ' 1800000',
    '1800000\nHIVERELAY_ATTACKER_VALUE=owned'
  ]

  for (const timeout of badTimeouts) {
    const out = await envFile(t)
    const res = await runCheck([
      '--channel', 'both',
      '--prerelease', 'false',
      '--github-env', out
    ], validDistributionEnv({
      FLEET_ROLLOUT_TIMEOUT_MS: timeout
    }))

    t.is(res.status, 1, `rejects timeout ${JSON.stringify(timeout)}`)
    t.ok(res.stderr.includes('FLEET_ROLLOUT_TIMEOUT_MS must be an integer between 600000 and 14400000 milliseconds'))

    const body = await readFile(out, 'utf8')
    t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=failed'))
    t.ok(body.includes('HIVERELAY_RELEASE_SURFACES_STATUS=blocked'))
    t.ok(body.includes('HIVERELAY_FLEET_ROLLOUT_STATUS=invalid-timeout'))
    t.absent(body.includes('HIVERELAY_ATTACKER_VALUE=owned'))
  }
})

test('release distribution env check rejects placeholder GitHub tokens before checkout or gh calls', async (t) => {
  const out = await envFile(t)
  const res = await runCheck([
    '--channel', 'both',
    '--prerelease', 'false',
    '--github-env', out
  ], validDistributionEnv({
    UMBREL_STORE_TOKEN: 'present',
    UMBREL_OFFICIAL_PR_TOKEN: 'not-a-token'
  }))

  t.is(res.status, 1)
  t.ok(res.stderr.includes('UMBREL_STORE_TOKEN must be a GitHub token without whitespace or control characters'))
  t.ok(res.stderr.includes('UMBREL_OFFICIAL_PR_TOKEN must be a GitHub token without whitespace or control characters'))
  t.ok(res.stderr.includes('Repair path:'))
  t.absent(res.stderr.includes(TEST_GITHUB_TOKEN))
  t.absent(res.stderr.includes(TEST_GITHUB_TOKEN_ALT))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=failed'))
  t.ok(body.includes('HIVERELAY_RELEASE_SURFACES_STATUS=blocked'))
  t.ok(body.includes('HIVERELAY_UMBREL_COMMUNITY_STORE_STATUS=invalid-token'))
  t.ok(body.includes('HIVERELAY_UMBREL_OFFICIAL_PR_STATUS=invalid-token'))
})

test('release distribution env check rejects malformed npm tokens before package publish', async (t) => {
  const out = await envFile(t)
  const res = await runCheck([
    '--channel', 'both',
    '--prerelease', 'false',
    '--github-env', out
  ], validDistributionEnv({
    NPM_TOKEN: 'not a token'
  }))

  t.is(res.status, 1)
  t.ok(res.stderr.includes('NPM_TOKEN must be an npm automation token without whitespace or control characters'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=failed'))
  t.ok(body.includes('HIVERELAY_RELEASE_SURFACES_STATUS=blocked'))
  t.ok(body.includes('HIVERELAY_NPM_PUBLISH_STATUS=invalid-token'))
})

test('release distribution env check rejects whitespace-padded GitHub tokens before checkout or gh calls', async (t) => {
  const out = await envFile(t)
  const res = await runCheck([
    '--channel', 'both',
    '--prerelease', 'false',
    '--github-env', out
  ], validDistributionEnv({
    UMBREL_STORE_TOKEN: `${TEST_GITHUB_TOKEN}\nHIVERELAY_ATTACKER_VALUE=owned`,
    UMBREL_OFFICIAL_PR_TOKEN: ` ${TEST_GITHUB_TOKEN_ALT}`
  }))

  t.is(res.status, 1)
  t.ok(res.stderr.includes('UMBREL_STORE_TOKEN must be a GitHub token without whitespace or control characters'))
  t.ok(res.stderr.includes('UMBREL_OFFICIAL_PR_TOKEN must be a GitHub token without whitespace or control characters'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=failed'))
  t.ok(body.includes('HIVERELAY_RELEASE_SURFACES_STATUS=blocked'))
  t.ok(body.includes('HIVERELAY_UMBREL_COMMUNITY_STORE_STATUS=invalid-token'))
  t.ok(body.includes('HIVERELAY_UMBREL_OFFICIAL_PR_STATUS=invalid-token'))
  t.absent(body.includes('HIVERELAY_ATTACKER_VALUE=owned'))
})

test('release distribution env check rejects placeholder private-key secrets before SSH or StartOS publish', async (t) => {
  const out = await envFile(t)
  const res = await runCheck([
    '--channel', 'both',
    '--prerelease', 'false',
    '--github-env', out
  ], validDistributionEnv({
    FLEET_SSH_PRIVATE_KEY: 'present',
    STARTOS_DEVELOPER_KEY_PEM: '-----BEGIN PUBLIC KEY-----\nfake\n-----END PUBLIC KEY-----'
  }))

  t.is(res.status, 1)
  t.ok(res.stderr.includes('FLEET_SSH_PRIVATE_KEY must be a private key block'))
  t.ok(res.stderr.includes('STARTOS_DEVELOPER_KEY_PEM must be a private key block'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=failed'))
  t.ok(body.includes('HIVERELAY_RELEASE_SURFACES_STATUS=blocked'))
  t.ok(body.includes('HIVERELAY_FLEET_ROLLOUT_STATUS=invalid-secret'))
  t.ok(body.includes('HIVERELAY_STARTOS_REGISTRY_STATUS=invalid-secret'))
})

test('release distribution env check rejects whitespace-padded private-key secrets before SSH or StartOS publish', async (t) => {
  const out = await envFile(t)
  const res = await runCheck([
    '--channel', 'both',
    '--prerelease', 'false',
    '--github-env', out
  ], validDistributionEnv({
    FLEET_SSH_PRIVATE_KEY: `${TEST_PRIVATE_KEY}\n`,
    STARTOS_DEVELOPER_KEY_PEM: ` ${TEST_STARTOS_PRIVATE_KEY}`
  }))

  t.is(res.status, 1)
  t.ok(res.stderr.includes('FLEET_SSH_PRIVATE_KEY must be a private key block'))
  t.ok(res.stderr.includes('STARTOS_DEVELOPER_KEY_PEM must be a private key block'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=failed'))
  t.ok(body.includes('HIVERELAY_RELEASE_SURFACES_STATUS=blocked'))
  t.ok(body.includes('HIVERELAY_FLEET_ROLLOUT_STATUS=invalid-secret'))
  t.ok(body.includes('HIVERELAY_STARTOS_REGISTRY_STATUS=invalid-secret'))
})

test('release distribution env check rejects non-https StartOS registry URLs before publish', async (t) => {
  const out = await envFile(t)
  const res = await runCheck([
    '--channel', 'both',
    '--prerelease', 'false',
    '--github-env', out
  ], validDistributionEnv({
    STARTOS_REGISTRY_URL: 'http://registry.example'
  }))

  t.is(res.status, 1)
  t.ok(res.stderr.includes('STARTOS_REGISTRY_URL must be a public https URL without embedded credentials'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=failed'))
  t.ok(body.includes('HIVERELAY_RELEASE_SURFACES_STATUS=blocked'))
  t.ok(body.includes('HIVERELAY_STARTOS_REGISTRY_STATUS=invalid-registry-url'))
})

test('release distribution env check rejects malformed official Umbrel fork names before checkout', async (t) => {
  const out = await envFile(t)
  const res = await runCheck([
    '--channel', 'both',
    '--prerelease', 'false',
    '--github-env', out
  ], validDistributionEnv({
    UMBREL_OFFICIAL_FORK: 'not a repo'
  }))

  t.is(res.status, 1)
  t.ok(res.stderr.includes('UMBREL_OFFICIAL_FORK must be a GitHub owner/umbrel-apps fork slug'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=failed'))
  t.ok(body.includes('HIVERELAY_RELEASE_SURFACES_STATUS=blocked'))
  t.ok(body.includes('HIVERELAY_UMBREL_OFFICIAL_PR_STATUS=invalid-fork'))
})

test('release distribution env check rejects renamed or option-like official Umbrel forks before checkout', async (t) => {
  const badForks = [
    'bigdestiny2/not-umbrel-apps',
    '-oProxyCommand/umbrel-apps',
    'bad_owner/umbrel-apps',
    'bad.owner/umbrel-apps',
    'getumbrel/umbrel-apps',
    'GetUmbrel/umbrel-apps'
  ]

  for (const fork of badForks) {
    const out = await envFile(t)
    const res = await runCheck([
      '--channel', 'both',
      '--prerelease', 'false',
      '--github-env', out
    ], validDistributionEnv({
      UMBREL_OFFICIAL_FORK: fork
    }))

    t.is(res.status, 1, `rejects ${fork}`)
    t.ok(res.stderr.includes('UMBREL_OFFICIAL_FORK must be a GitHub owner/umbrel-apps fork slug'))

    const body = await readFile(out, 'utf8')
    t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=failed'))
    t.ok(body.includes('HIVERELAY_RELEASE_SURFACES_STATUS=blocked'))
    t.ok(body.includes('HIVERELAY_UMBREL_OFFICIAL_PR_STATUS=invalid-fork'))
  }
})

test('release distribution env check rejects credentialed StartOS registry URLs before publish', async (t) => {
  const out = await envFile(t)
  const res = await runCheck([
    '--channel', 'both',
    '--prerelease', 'false',
    '--github-env', out
  ], validDistributionEnv({
    STARTOS_REGISTRY_URL: 'https://user:pass@registry.example'
  }))

  t.is(res.status, 1)
  t.ok(res.stderr.includes('STARTOS_REGISTRY_URL must be a public https URL without embedded credentials'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=failed'))
  t.ok(body.includes('HIVERELAY_RELEASE_SURFACES_STATUS=blocked'))
  t.ok(body.includes('HIVERELAY_STARTOS_REGISTRY_STATUS=invalid-registry-url'))
})

test('release distribution env check rejects control characters before GitHub env writes', async (t) => {
  const out = await envFile(t)
  const res = await runCheck([
    '--channel', 'both',
    '--prerelease', 'false',
    '--github-env', out
  ], validDistributionEnv({
    STARTOS_REGISTRY_URL: 'https://registry.start9.com\nmalicious.example'
  }))

  t.is(res.status, 1)
  t.ok(res.stderr.includes('STARTOS_REGISTRY_URL must be a public https URL without embedded credentials'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=failed'))
  t.ok(body.includes('HIVERELAY_RELEASE_SURFACES_STATUS=blocked'))
  t.ok(body.includes('HIVERELAY_STARTOS_REGISTRY_STATUS=invalid-registry-url'))
  t.absent(body.includes('malicious.example'))
})

test('release distribution env check defaults full releases to whole-fleet promotion', async (t) => {
  const out = await envFile(t)
  const res = await runCheck([
    '--prerelease', 'false',
    '--github-env', out
  ], validDistributionEnv())

  t.is(res.status, 0)
  t.ok(res.stdout.includes('preflight passed'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_RELEASE_EFFECTIVE_CHANNEL=both'))
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=passed'))
  t.absent(body.includes('HIVERELAY_FLEET_ROLLOUT_STATUS=missing-channel'))
  t.absent(body.includes('blocked'))
})

test('release distribution env check rejects placeholder StartOS registry hosts before publish', async (t) => {
  const out = await envFile(t)
  const res = await runCheck([
    '--channel', 'both',
    '--prerelease', 'false',
    '--github-env', out
  ], validDistributionEnv({
    STARTOS_REGISTRY_URL: 'https://registry.example/startos'
  }))

  t.is(res.status, 1)
  t.ok(res.stderr.includes('reserved/local hostnames'))

  const body = await readFile(out, 'utf8')
  t.ok(body.includes('HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=failed'))
  t.ok(body.includes('HIVERELAY_RELEASE_SURFACES_STATUS=blocked'))
  t.ok(body.includes('HIVERELAY_STARTOS_REGISTRY_STATUS=invalid-registry-url'))
})