#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(here, '..')
const DEFAULT_REPO = 'bigdestiny2/P2P-Hiverelay'
const DEFAULT_BLOCKER_LOG = path.join(root, 'docs', 'ship-handoff', 'issue-120-release-distribution-preflight.txt')
const VALID_GITHUB_TOKEN = `ghp_${'a'.repeat(36)}`
const VALID_GITHUB_TOKEN_ALT = `gho_${'b'.repeat(36)}`
const VALID_NPM_TOKEN = `npm_${'c'.repeat(36)}`
const VALID_FLEET_KEY = fakePrivateKeyBlock('OPENSSH PRIVATE KEY', 'fake-fleet-key')
const VALID_STARTOS_KEY = fakePrivateKeyBlock('PRIVATE KEY', 'fake-startos-key')
const usage = `
Usage:
node scripts/update-ship-handoff.mjs [--date YYYY-MM-DD] [--output docs/SHIP-HANDOFF-YYYY-MM-DD.md]
[--ref <git-ref>] [--branch <name>] [--pr <number>] [--issue <number>]
[--test-run <id>] [--docker-run <id>] [--preflight-run <id>]
[--preflight-url <url>] [--preflight-head <sha>] [--preflight-branch <name>]
[--preflight-created-at <iso>] [--preflight-state <text>]
[--superseded-preflight-success <id>] [--superseded-preflight-success-head <sha>]
[--blocker-log <path>] [--repo owner/name] [--check]
Examples:
npm run docs:update-ship-handoff -- --date 2026-06-26 --ref HEAD
npm run docs:update-ship-handoff -- --date 2026-06-26 --ref 683ef0a --branch main --pr 136 --test-run 28249805132 --docker-run 28249805122 --preflight-run 28244297762
`
if (isMain()) {
main()
}
export function renderShipHandoff (facts) {
const blockerLines = facts.releaseBlockers.map(item => `- \`${item}\``)
const privateKeyLine = blockersMentionPrivateKeys(facts.releaseBlockers)
? ''
: '\nThe same preflight did not report malformed `FLEET_SSH_PRIVATE_KEY` or\n`STARTOS_DEVELOPER_KEY_PEM`.\n'
const checkSummary = facts.prCheckNames.length > 0
? `${joinHuman(facts.prCheckNames)} passed.`
: 'audit, lint, unit, integration, and Bare tests passed.'
const prReference = facts.prNumber ? `PR #${facts.prNumber}` : 'the inspected commit'
const preflightRun = facts.preflightRun
? `\`${facts.preflightRun}\``
: 'the release-distribution preflight'
const preflightDetails = preflightDetailLines(facts)
return `${[
`# HiveRelay Ship Handoff - ${facts.date}`,
'',
'<!-- Generated by `npm run docs:update-ship-handoff`; edit the generator inputs instead of hand-editing snapshot facts. -->',
'',
'This is the current ship boundary after the Core3/HiveRelay release-distribution,',
`Docker/runtime, and env-file hardening work through ${prReference}.`,
'',
'## Current Source State',
'',
`- Branch: \`${facts.branch}\``,
`- HEAD inspected: \`${facts.shortSha}\``,
`- Commit subject: \`${facts.commitSubject}\``,
`- Worktree: ${formatWorktreeSummary(facts)}`,
`- Package version: \`${facts.packageName}@${facts.packageVersion}\``,
'- Architecture graph: `docs/HIVERELAY-ARCHITECTURE-GRAPH.md` and',
' `docs/assets/hiverelay-core3-architecture.svg`',
'',
'## Verified Green',
'',
facts.prNumber ? `- PR #${facts.prNumber} CI finished green:` : '- Inspected commit CI finished green:',
` ${checkSummary}`,
facts.testRun ? `- Post-merge main Test run: \`${facts.testRun}\` passed.` : null,
facts.dockerRun ? `- Post-merge Docker snapshot publish: \`${facts.dockerRun}\` passed.` : null,
'- Release default probes were regenerated from',
' `scripts/check-release-distribution-env.mjs`:',
` - Full releases with no explicit channel resolve to \`${facts.releaseDefaults.full}\`.`,
` - Prereleases with no explicit channel resolve to \`${facts.releaseDefaults.prerelease}\`.`,
'- Release image publication is gated on',
' `node --test test/unit/ecosystem-consumers.test.js`, keeping the',
' PearBrowser, PearPaste, anonGPT, and direct-consumer default-pinning',
' inventory visible in the workflow log before any image is published.',
'- Local ship-loop ecosystem parity is gated by',
' `npm run ecosystem:sync -- --check` and',
' `npm run audit:ecosystem-consumers`, so known app defaults and linked',
' lockfile metadata plus versioned source markers stay on the newest',
' HiveRelay workspace line.',
'',
'## Current Red',
'',
`Release distribution preflight run: ${preflightRun} (issue #${facts.issueNumber}).`,
...preflightDetails,
'',
'The run is side-effect-free and is failing on malformed GitHub-hosted release',
'values, not missing repo code:',
'',
...blockerLines,
privateKeyLine.trimEnd(),
'',
'StartOS package proof is also release-image gated: do not treat the',
`\`v${facts.packageVersion}\` package as proven until`,
`\`ghcr.io/bigdestiny2/p2p-hiverelay:${facts.packageVersion}\` resolves to a`,
'multi-arch manifest digest, `make digest` succeeds, and',
'`HIVERELAY_IMAGE_DIGEST=sha256:<digest> npm run startos:verify` passes.',
'',
'## Repair Path',
'',
'Generate a targeted local candidate env file with the corrected issue #120 values,',
'then run:',
'',
'```sh',
'npm run release:write-secret-template -- \\',
' --issue-120-repair \\',
' --out /private/tmp/hiverelay-release-secrets.env',
'',
'npm run release:check-distribution-env -- \\',
' --issue-120-repair \\',
' --env-file /private/tmp/hiverelay-release-secrets.env',
'',
'npm run release:apply-github-secrets -- \\',
' --issue-120-repair \\',
` --repo ${facts.repo} \\`,
' --env-file /private/tmp/hiverelay-release-secrets.env \\',
' --dry-run',
'',
'npm run release:apply-github-secrets -- \\',
' --issue-120-repair \\',
` --repo ${facts.repo} \\`,
' --env-file /private/tmp/hiverelay-release-secrets.env',
'```',
'',
'The template command writes placeholders only for the four current issue #120',
'masked-value blockers and keeps the file outside the repo.',
'The check command validates the candidate file locally without publishing.',
'The apply helper validates the same file again before writing only those four',
'GitHub Secrets. Masked release values are sent through `gh secret set` stdin',
'and are not printed.',
'',
'After applying the fixed values, rerun:',
'',
'```sh',
'gh workflow run release-distribution-preflight.yml \\',
` --repo ${facts.repo} \\`,
' -f channel=both \\',
' -f prerelease=false',
'```',
'',
'## Live Ship Sequence',
'',
'Do not cut a full live release until the distribution preflight passes.',
'',
'1. Generate `/private/tmp/hiverelay-release-secrets.env` with `release:write-secret-template -- --issue-120-repair` and replace every `REPLACE_*` placeholder.',
'2. Rotate the four malformed GitHub values using `release:apply-github-secrets -- --issue-120-repair`.',
'3. Rerun the full-release preflight with `channel=both` and `prerelease=false`.',
'4. Cut a fresh versioned release through `release-surfaces.yml`.',
'5. Confirm the release GHCR tag resolves to a multi-arch digest and the',
' StartOS package verifies from that exact digest.',
'6. Verify public evidence sidecars:',
' `release-evidence.json`, `fleet-rollout-evidence.json`,',
' `official-umbrel-pr-evidence.json`, `startos-registry-evidence.json`,',
' `release-image-manifest-evidence.json`, and package smoke evidence.',
'7. For review-ready Umbrel handoff, capture real-device setup/add-wallet/service',
' management evidence and run `npm run release:verify-review-ready-handoff`.',
'',
'## Ship Status',
'',
'Repo-side release code, Docker runtime hardening, release default behavior,',
'and architecture documentation are ready. Live fleet/store promotion is still',
'externally gated until the malformed GitHub-hosted release values are',
'corrected and a fresh passing full-release preflight exists.',
''
].filter(line => line !== null).join('\n')}`
}
export function parseReleaseBlockers (stderr) {
const blockers = []
let inBlockers = false
for (const raw of String(stderr || '').split(/\r?\n/)) {
const line = raw.trim()
if (line === 'Release distribution preflight failed:') {
inBlockers = true
continue
}
if (!inBlockers) continue
if (!line || line === 'Repair path:') {
if (blockers.length > 0) break
continue
}
const match = /^-\s+(.+)$/.exec(line)
if (!match) {
if (blockers.length > 0) break
continue
}
blockers.push(match[1])
}
return blockers
}
export function formatWorktreeSummary (facts) {
const worktree = facts.worktree
if (!worktree) return 'unknown'
if (!facts.refIsCurrentCheckout) {
return `clean snapshot (\`${facts.branch}@${facts.shortSha}\`)`
}
const state = worktree.clean
? 'clean'
: `dirty (${worktree.changedPaths} changed/untracked paths)`
const checkout = worktree.statusLine ? `\`${worktree.statusLine}\`` : '`unknown checkout`'
return `${state} (${checkout})`
}
function preflightDetailLines (facts) {
const lines = []
const state = String(facts.preflightState || '').trim()
const created = String(facts.preflightCreatedAt || '').trim()
const branch = String(facts.preflightBranch || '').trim()
const head = String(facts.preflightHead || '').trim()
const url = String(facts.preflightUrl || '').trim()
const supersededSuccess = String(facts.supersededPreflightSuccess || '').trim()
const supersededHead = String(facts.supersededPreflightSuccessHead || '').trim()
if (url) lines.push(`- Run URL: ${url}`)
const stateParts = []
if (state) stateParts.push(`state \`${state}\``)
if (branch || head) stateParts.push(`head \`${formatHeadRef(branch, head)}\``)
if (created) stateParts.push(`created \`${created}\``)
if (stateParts.length > 0) lines.push(`- Latest checked preflight: ${stateParts.join(', ')}.`)
if (supersededSuccess) {
const headText = supersededHead ? ` at \`${shortSha(supersededHead)}\`` : ''
lines.push(`- Earlier passing preflight \`${supersededSuccess}\`${headText} is superseded by this newer failure.`)
}
return lines
}
function formatHeadRef (branch, head) {
if (branch && head) return `${branch}@${shortSha(head)}`
if (head) return shortSha(head)
return branch
}
function shortSha (value) {
return String(value || '').slice(0, 7)
}
function main () {
const args = parseArgs(process.argv.slice(2))
const date = args.date || new Date().toISOString().slice(0, 10)
const output = path.resolve(root, args.output || path.join('docs', `SHIP-HANDOFF-${date}.md`))
const ref = args.ref || 'HEAD'
const facts = collectFacts({
date,
ref,
branch: args.branch,
prNumber: args.pr,
issueNumber: args.issue || '120',
repo: args.repo || DEFAULT_REPO,
testRun: args.testRun || process.env.HIVERELAY_HANDOFF_TEST_RUN || '',
dockerRun: args.dockerRun || process.env.HIVERELAY_HANDOFF_DOCKER_RUN || '',
preflightRun: args.preflightRun || process.env.HIVERELAY_HANDOFF_PREFLIGHT_RUN || '',
preflightUrl: args.preflightUrl || process.env.HIVERELAY_HANDOFF_PREFLIGHT_URL || '',
preflightHead: args.preflightHead || process.env.HIVERELAY_HANDOFF_PREFLIGHT_HEAD || '',
preflightBranch: args.preflightBranch || process.env.HIVERELAY_HANDOFF_PREFLIGHT_BRANCH || '',
preflightCreatedAt: args.preflightCreatedAt || process.env.HIVERELAY_HANDOFF_PREFLIGHT_CREATED_AT || '',
preflightState: args.preflightState || process.env.HIVERELAY_HANDOFF_PREFLIGHT_STATE || '',
supersededPreflightSuccess: args.supersededPreflightSuccess || process.env.HIVERELAY_HANDOFF_SUPERSEDED_PREFLIGHT_SUCCESS || '',
supersededPreflightSuccessHead: args.supersededPreflightSuccessHead || process.env.HIVERELAY_HANDOFF_SUPERSEDED_PREFLIGHT_SUCCESS_HEAD || '',
blockerLog: path.resolve(root, args.blockerLog || DEFAULT_BLOCKER_LOG)
})
const doc = renderShipHandoff(facts)
if (args.check) {
let existing = ''
try {
existing = fs.readFileSync(output, 'utf8')
} catch (err) {
die(`Unable to read ${path.relative(root, output)} for --check: ${sanitizeError(err)}`)
}
if (existing !== doc) die(`${path.relative(root, output)} is out of date; run npm run docs:update-ship-handoff`)
console.log(`${path.relative(root, output)} is up to date.`)
return
}
fs.mkdirSync(path.dirname(output), { recursive: true })
fs.writeFileSync(output, doc)
console.log(`Wrote ${path.relative(root, output)}`)
}
function collectFacts (opts) {
const fullSha = git(['rev-parse', opts.ref])
const headSha = git(['rev-parse', 'HEAD'])
const shortSha = git(['rev-parse', '--short', opts.ref])
const commitSubject = git(['log', '-1', '--format=%s', opts.ref])
const packageJson = readPackageAtRef(opts.ref)
const branch = opts.branch || git(['branch', '--show-current']) || opts.ref
const worktree = collectWorktree()
const releaseBlockers = collectReleaseBlockers(opts.blockerLog)
const releaseDefaults = collectReleaseDefaults()
return {
date: opts.date,
ref: opts.ref,
refIsCurrentCheckout: refTargetsCurrentCheckout(opts.ref, branch, fullSha, headSha),
branch,
shortSha,
commitSubject,
packageName: packageJson.name,
packageVersion: packageJson.version,
worktree,
prNumber: String(opts.prNumber || inferPullRequestNumber(commitSubject) || ''),
prCheckNames: ['audit', 'lint', 'unit', 'integration', 'Bare tests'],
testRun: String(opts.testRun),
dockerRun: String(opts.dockerRun),
issueNumber: String(opts.issueNumber),
preflightRun: String(opts.preflightRun),
preflightUrl: String(opts.preflightUrl),
preflightHead: String(opts.preflightHead),
preflightBranch: String(opts.preflightBranch),
preflightCreatedAt: String(opts.preflightCreatedAt),
preflightState: String(opts.preflightState),
supersededPreflightSuccess: String(opts.supersededPreflightSuccess),
supersededPreflightSuccessHead: String(opts.supersededPreflightSuccessHead),
repo: opts.repo,
releaseBlockers,
releaseDefaults
}
}
function inferPullRequestNumber (subject) {
const match = /\(#([0-9]+)\)$/.exec(String(subject || '').trim())
return match ? match[1] : ''
}
function refTargetsCurrentCheckout (ref, branch, fullSha, headSha) {
const name = String(ref || '')
return fullSha === headSha && (name === 'HEAD' || name === '@' || name === branch)
}
function collectWorktree () {
const status = git(['status', '--short', '--branch'])
const lines = status.split(/\r?\n/).filter(Boolean)
const statusLine = (lines[0] || '').replace(/^##\s+/, '')
const changes = lines.slice(1)
return {
statusLine,
clean: changes.length === 0,
changedPaths: changes.length
}
}
function collectReleaseBlockers (blockerLog) {
let text = ''
try {
text = fs.readFileSync(blockerLog, 'utf8')
} catch (err) {
die(`Unable to read release blocker log ${path.relative(root, blockerLog)}: ${sanitizeError(err)}`)
}
const blockers = parseReleaseBlockers(text)
if (blockers.length === 0) die('Release blocker preflight failed without parseable blocker facts')
return blockers
}
function collectReleaseDefaults () {
return {
full: collectReleaseDefault(false),
prerelease: collectReleaseDefault(true)
}
}
function collectReleaseDefault (prerelease) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hiverelay-ship-handoff-'))
const githubEnv = path.join(dir, 'github-env')
try {
const result = spawnSync(process.execPath, [
path.join(here, 'check-release-distribution-env.mjs'),
'--prerelease',
prerelease ? 'true' : 'false',
'--github-env',
githubEnv
], {
cwd: root,
encoding: 'utf8',
env: prerelease ? { PATH: process.env.PATH || '' } : validDistributionEnv(),
maxBuffer: 1024 * 1024
})
if (result.error) die(`Unable to probe release defaults: ${result.error.message}`)
if (result.status !== 0) die(`Release default probe failed: ${(result.stderr || result.stdout).trim()}`)
const env = parseGithubEnv(fs.readFileSync(githubEnv, 'utf8'))
return env.HIVERELAY_RELEASE_EFFECTIVE_CHANNEL || ''
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
}
function validDistributionEnv () {
return {
PATH: process.env.PATH || '',
FLEET_SSH_PRIVATE_KEY: VALID_FLEET_KEY,
UMBREL_STORE_TOKEN: VALID_GITHUB_TOKEN,
UMBREL_OFFICIAL_PR_TOKEN: VALID_GITHUB_TOKEN_ALT,
UMBREL_OFFICIAL_FORK: 'bigdestiny2/umbrel-apps',
NPM_TOKEN: VALID_NPM_TOKEN,
STARTOS_DEVELOPER_KEY_PEM: VALID_STARTOS_KEY,
STARTOS_REGISTRY_URL: 'https://registry.start9.com'
}
}
function parseGithubEnv (text) {
const out = {}
for (const line of String(text || '').split(/\r?\n/)) {
if (!line) continue
const index = line.indexOf('=')
if (index === -1) continue
out[line.slice(0, index)] = line.slice(index + 1)
}
return out
}
function readPackageAtRef (ref) {
return JSON.parse(git(['show', `${ref}:package.json`]))
}
function git (args) {
const result = spawnSync('git', args, {
cwd: root,
encoding: 'utf8',
maxBuffer: 1024 * 1024
})
if (result.error) die(`git ${args.join(' ')} failed: ${result.error.message}`)
if (result.status !== 0) {
die(`git ${args.join(' ')} failed: ${(result.stderr || result.stdout).trim()}`)
}
return result.stdout.trim()
}
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 === '--check') {
out.check = true
continue
}
if (arg === '--date') {
out.date = readValue(argv, ++i, arg)
continue
}
if (arg === '--output') {
out.output = readValue(argv, ++i, arg)
continue
}
if (arg === '--ref') {
out.ref = readValue(argv, ++i, arg)
continue
}
if (arg === '--branch') {
out.branch = readValue(argv, ++i, arg)
continue
}
if (arg === '--pr') {
out.pr = readValue(argv, ++i, arg)
continue
}
if (arg === '--issue') {
out.issue = readValue(argv, ++i, arg)
continue
}
if (arg === '--repo') {
out.repo = readValue(argv, ++i, arg)
continue
}
if (arg === '--test-run') {
out.testRun = readValue(argv, ++i, arg)
continue
}
if (arg === '--docker-run') {
out.dockerRun = readValue(argv, ++i, arg)
continue
}
if (arg === '--preflight-run') {
out.preflightRun = readValue(argv, ++i, arg)
continue
}
if (arg === '--preflight-url') {
out.preflightUrl = readValue(argv, ++i, arg)
continue
}
if (arg === '--preflight-head') {
out.preflightHead = readValue(argv, ++i, arg)
continue
}
if (arg === '--preflight-branch') {
out.preflightBranch = readValue(argv, ++i, arg)
continue
}
if (arg === '--preflight-created-at') {
out.preflightCreatedAt = readValue(argv, ++i, arg)
continue
}
if (arg === '--preflight-state') {
out.preflightState = readValue(argv, ++i, arg)
continue
}
if (arg === '--superseded-preflight-success') {
out.supersededPreflightSuccess = readValue(argv, ++i, arg)
continue
}
if (arg === '--superseded-preflight-success-head') {
out.supersededPreflightSuccessHead = readValue(argv, ++i, arg)
continue
}
if (arg === '--blocker-log') {
out.blockerLog = readValue(argv, ++i, arg)
continue
}
die(`Unknown argument: ${arg}`)
}
return out
}
function readValue (argv, index, flag) {
const value = argv[index]
if (!value || value.startsWith('--')) die(`Missing value for ${flag}`)
return value
}
function blockersMentionPrivateKeys (blockers) {
return blockers.some(item => item.startsWith('FLEET_SSH_PRIVATE_KEY') || item.startsWith('STARTOS_DEVELOPER_KEY_PEM'))
}
function fakePrivateKeyBlock (label, body) {
return [`-----BEGIN ${label}-----`, body, `-----END ${label}-----`].join('\n')
}
function joinHuman (items) {
if (items.length <= 1) return items.join('')
return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`
}
function isMain () {
return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
}
function sanitizeError (err) {
return err && err.code ? String(err.code) : 'unknown error'
}
function die (message) {
console.error(message)
process.exit(1)
}