#!/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)
}