/**
* ReleaseManager — per-repo versions + channels layered on top of a
* hyper-deploy release directory.
*
* Directory layout:
*
* <releaseDir>/
* <repo>/
* versions/
* <version>/
* <artifact-files>
* manifest.json { version, builtAt, fromCommit, artifacts[], sizeBytes }
* channels/
* <channel>.json { version, pinnedAt }
*
* Versions are immutable once published. Channels are mutable pointers
* (small JSON, not symlinks — portable + inspectable). Fetching
* <repo>/channels/<channel>.json tells a peer which version is current;
* fetching <repo>/versions/<v>/* is content-stable.
*
* Mutating methods auto-trigger hyper-deploy.rescan() so peers see
* changes without an extra round-trip.
*/
import { mkdir, writeFile, readFile, readdir, stat, rm } from '#node:fs/promises'
import { join, dirname } from '#node:path'
import { hashChunk } from 'hyper-overlay/chunker'
import {
OP_PUBLISHED, OP_PROMOTED, OP_UNPINNED, OP_DELETED
} from './services/release-event-log.js'
const MANIFEST_FILE = 'manifest.json'
// Written into the version dir by the vdrive step (vdrive-publish.js
// copyLocations) so the drive's contentHash -> (file, offset, length) map
// replicates with the release. Named here because ReleaseManager owns what a
// version directory may contain, and _writeIndex has to know to index it.
export const VDRIVE_LOCATIONS_FILE = 'locations.json'
// Files a version carries that are NOT listed in manifest.artifacts. Anything
// omitted here is invisible to _writeIndex and therefore never replicates.
const RESERVED_VERSION_FILES = [MANIFEST_FILE, VDRIVE_LOCATIONS_FILE]
// Overlay namespace for release artifacts (the attachment spool shares this overlay).
export const RELEASE_OVERLAY_PREFIX = 'release:'
// Well-known discovery path at the release-dir root. See _writeIndex().
const INDEX_FILE = '_index.json'
export class ReleaseManager {
/**
* @param {object} opts
* @param {string} opts.releaseDir - absolute path to the top-level release root
* @param {object} [opts.deploy] - hyper-deploy DeployServer, for auto-rescan
* @param {object} [opts.eventLog] - ReleaseEventLog. When present AND writable
* (i.e. this daemon is the origin), every mutation is announced to peers so
* mirrors reconcile on the event instead of on a polling interval.
*/
constructor (opts = {}) {
if (!opts.releaseDir) throw new Error('ReleaseManager requires { releaseDir }')
this._releaseDir = opts.releaseDir
this._deploy = opts.deploy || null
// Optional: the V2 overlay. When present, _writeIndex registers every
// artifact by content hash so peers can fetch the bytes the index names.
this._overlay = opts.overlay || null
this._eventLog = opts.eventLog || null
}
/** Release root on disk — the version dirs under it are the vdrive byte store. */
get releaseDir () { return this._releaseDir }
// ── Path helpers ───────────────────────────────────────────
_repoDir (repo) { return join(this._releaseDir, repo) }
_versionsDir (repo) { return join(this._releaseDir, repo, 'versions') }
_versionDir (repo, ver) { return join(this._releaseDir, repo, 'versions', ver) }
_channelsDir (repo) { return join(this._releaseDir, repo, 'channels') }
_channelPath (repo, chan) { return join(this._releaseDir, repo, 'channels', chan + '.json') }
// ── Publish: write a new version + optionally pin a channel ──
/**
* @param {object} opts
* @param {string} opts.repo
* @param {string} opts.version
* @param {Array<{path: string, data: Buffer}>} opts.artifacts
* @param {string} [opts.channel]
* @param {object} [opts.meta] - extra fields merged into manifest.json
*/
async publish (opts) {
const { repo, version, artifacts, channel, meta } = opts
if (!repo || !version) throw new Error('publish requires { repo, version }')
if (!Array.isArray(artifacts) || artifacts.length === 0) {
throw new Error('publish requires artifacts: [{ path, data }]')
}
validateName(repo, 'repo')
validateName(version, 'version')
const verDir = this._versionDir(repo, version)
// Guard: don't silently overwrite an existing version's files. The
// caller must delete first if they really want to replace.
try {
await stat(verDir)
throw new Error(`version ${version} already exists for ${repo} — delete it first to republish`)
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
await mkdir(verDir, { recursive: true })
const written = []
for (const a of artifacts) {
if (!a || !a.path || !Buffer.isBuffer(a.data) && !(a.data instanceof Uint8Array) && typeof a.data !== 'string') {
throw new Error('each artifact requires { path, data: Buffer|string }')
}
validatePath(a.path)
const abs = join(verDir, a.path)
await mkdir(dirname(abs), { recursive: true })
const buf = Buffer.isBuffer(a.data) ? a.data : Buffer.from(a.data)
await writeFile(abs, buf)
written.push({ path: a.path, size: buf.length })
}
const manifest = {
version,
builtAt: Date.now(),
fromCommit: (meta && meta.fromCommit) || null,
artifacts: written,
sizeBytes: written.reduce((s, a) => s + a.size, 0),
...(meta || {})
}
await writeFile(join(verDir, MANIFEST_FILE), JSON.stringify(manifest, null, 2))
if (channel) await this._writeChannel(repo, channel, version)
await this._rescan({ op: OP_PUBLISHED, repo, version, channel: channel || '' })
return { repo, version, channel: channel || null, manifest }
}
// ── Stream-friendly publish for binary uploads ────────────
/**
* Lower-level alternative to publish() for large artifacts uploaded
* via HTTP streaming. Callers use startVersion → writeArtifact × N →
* finalizeVersion so bytes never have to be buffered server-side.
*/
async startVersion (repo, version) {
validateName(repo, 'repo')
validateName(version, 'version')
const verDir = this._versionDir(repo, version)
try {
await stat(verDir)
throw new Error(`version ${version} already exists for ${repo}`)
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
await mkdir(verDir, { recursive: true })
return verDir
}
async writeArtifact (repo, version, path, data) {
validateName(repo, 'repo')
validateName(version, 'version')
validatePath(path)
const abs = join(this._versionDir(repo, version), path)
await mkdir(dirname(abs), { recursive: true })
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data)
await writeFile(abs, buf)
return { path, size: buf.length }
}
async finalizeVersion (repo, version, { channel, meta } = {}) {
validateName(repo, 'repo')
validateName(version, 'version')
const verDir = this._versionDir(repo, version)
// Walk the version dir to build the manifest's artifacts list
const walk = async (dir, prefix = '') => {
const out = []
const entries = await readdir(dir, { withFileTypes: true })
for (const e of entries) {
if (e.name === MANIFEST_FILE) continue
const rel = prefix ? prefix + '/' + e.name : e.name
if (e.isDirectory()) {
out.push(...(await walk(join(dir, e.name), rel)))
} else if (e.isFile()) {
const s = await stat(join(dir, e.name))
out.push({ path: rel, size: s.size })
}
}
return out
}
const written = await walk(verDir)
const manifest = {
version,
builtAt: Date.now(),
fromCommit: (meta && meta.fromCommit) || null,
artifacts: written,
sizeBytes: written.reduce((s, a) => s + a.size, 0),
...(meta || {})
}
await writeFile(join(verDir, MANIFEST_FILE), JSON.stringify(manifest, null, 2))
if (channel) await this._writeChannel(repo, channel, version)
await this._rescan({ op: OP_PUBLISHED, repo, version, channel: channel || '' })
return { repo, version, channel: channel || null, manifest }
}
// ── Channel ops ───────────────────────────────────────────
async _writeChannel (repo, channel, version) {
validateName(channel, 'channel')
await mkdir(this._channelsDir(repo), { recursive: true })
await writeFile(this._channelPath(repo, channel), JSON.stringify({
version,
pinnedAt: Date.now()
}, null, 2))
}
/**
* Re-index a published version whose files changed out of band, and wake the
* mirrors.
*
* Publishing is not the last thing that writes into a version directory. The
* vdrive step runs afterwards: it rewrites manifest.json to carry the
* pear:// link and drops locations.json beside it. Both happen AFTER publish()
* hashed the version into _index.json, so without this the index advertises a
* content hash for bytes that no longer exist — a mirror fetches every
* artifact, fails on the manifest, and the version reads `incomplete` on the
* far side for ever. Measured in prod: manifest 665 B indexed, 880 B on disk,
* nine seconds apart (#18).
*
* Idempotent, and safe to call when nothing changed: _writeIndex keeps a
* hash cache keyed on (path, size, mtime), so unchanged artifacts cost a
* stat rather than a re-hash. The event is a wake-up the mirror reacts to by
* re-reading the index, so a duplicate is harmless.
*/
async reannounce (repo, version, channel) {
if (!repo || !version) throw new Error('reannounce requires { repo, version }')
await this._rescan({ op: OP_PUBLISHED, repo, version, channel: channel || '' })
return { repo, version, channel: channel || '' }
}
async promote (repo, version, toChannel) {
if (!repo || !version || !toChannel) {
throw new Error('promote requires { repo, version, toChannel }')
}
// Version must exist before we pin a channel to it
try {
await stat(this._versionDir(repo, version))
} catch {
throw new Error(`version ${version} not found for repo ${repo}`)
}
await this._writeChannel(repo, toChannel, version)
await this._rescan({ op: OP_PROMOTED, repo, version, channel: toChannel })
return { repo, version, channel: toChannel }
}
async unpinChannel (repo, channel) {
if (!repo || !channel) throw new Error('unpinChannel requires { repo, channel }')
try { await rm(this._channelPath(repo, channel)) } catch (err) {
if (err.code !== 'ENOENT') throw err
}
await this._rescan({ op: OP_UNPINNED, repo, channel })
return { repo, channel, unpinned: true }
}
// ── Read ops ──────────────────────────────────────────────
async list (repo) {
if (repo) return this._listOne(repo)
const entries = await readdir(this._releaseDir, { withFileTypes: true }).catch(() => [])
const all = []
for (const e of entries) {
if (e.isDirectory()) all.push(await this._listOne(e.name))
}
return all
}
async _listOne (repo) {
const versions = []
try {
const verNames = await readdir(this._versionsDir(repo))
for (const v of verNames) {
try {
const raw = await readFile(join(this._versionDir(repo, v), MANIFEST_FILE), 'utf8')
versions.push(JSON.parse(raw))
} catch {
versions.push({ version: v, incomplete: true })
}
}
} catch {}
versions.sort((a, b) => (b.builtAt || 0) - (a.builtAt || 0))
const channels = {}
try {
const chanFiles = await readdir(this._channelsDir(repo))
for (const f of chanFiles) {
if (!f.endsWith('.json')) continue
const name = f.slice(0, -5)
try {
channels[name] = JSON.parse(await readFile(this._channelPath(repo, name), 'utf8'))
} catch {}
}
} catch {}
return { repo, versions, channels }
}
async getManifest (repo, version) {
try {
const raw = await readFile(join(this._versionDir(repo, version), MANIFEST_FILE), 'utf8')
return JSON.parse(raw)
} catch {
return null
}
}
// ── Delete / GC ────────────────────────────────────────────
/** Unlink a version's bytes. No reindex, no event -- callers own those. */
async _removeVersionDir (repo, version) {
await rm(this._versionDir(repo, version), { recursive: true, force: true })
}
async deleteVersion (repo, version) {
if (!repo || !version) throw new Error('deleteVersion requires { repo, version }')
await this._removeVersionDir(repo, version)
await this._rescan({ op: OP_DELETED, repo, version })
return { repo, version, deleted: true }
}
/**
* Garbage-collect stale versions.
*
* Default policy: keep any version pinned by any channel, plus the N
* newest unpinned versions. Any older unpinned versions are candidates.
*
* @param {object} [opts]
* @param {string} [opts.repo] - restrict GC to one repo
* @param {number} [opts.keepN=3] - newest unpinned to keep per repo
* @param {boolean} [opts.dryRun] - report candidates without deleting
*/
async gc (opts = {}) {
const { repo, keepN = 3, dryRun = false } = opts
const info = repo ? [await this._listOne(repo)] : await this.list()
const candidates = []
for (const r of info) {
const pinned = new Set(Object.values(r.channels || {}).map(c => c.version))
const unpinned = r.versions.filter(v => !pinned.has(v.version))
// versions already sorted newest-first by _listOne
for (const v of unpinned.slice(keepN)) {
candidates.push({ repo: r.repo, version: v.version, reason: 'stale-unpinned' })
}
}
const deleted = []
if (!dryRun) {
// Unlink EVERY candidate first, then reindex ONCE, then announce.
//
// Calling deleteVersion per candidate reindexed per candidate, and
// _writeIndex walks every artifact in the release root and re-registers
// each with the overlay -- so collecting N versions rebuilt the whole
// index N times, synchronously, on the daemon's event loop. Collecting
// favela-android on stortron (1117 indexed files) held ~45% of a core for
// minutes and flapped the public forge 000/200 at roughly 90s per version
// until the operator killed it (2026-08-29). The ~150 MB of unlinks was
// never the cost; the repeated reindex was.
for (const c of candidates) {
try {
await this._removeVersionDir(c.repo, c.version)
deleted.push(c)
} catch {}
}
if (deleted.length) {
// ORDER IS LOAD-BEARING: reindex BEFORE any event. A mirror reacts to a
// tombstone within milliseconds, so announcing while the index still
// advertised the dead version would send it after bytes nobody holds --
// the "no holder" failure in services/deleted-releases.js. Pinned by
// release-gc-batched-rescan.test.js and release-index-serve-order.test.js.
await this._reindex()
for (const c of deleted) {
await this._emit({ op: OP_DELETED, repo: c.repo, version: c.version })
}
}
}
return { candidates, deleted, dryRun }
}
// ── Internal ──────────────────────────────────────────────
/**
* Regenerate `<releaseDir>/_index.json` — the ONE well-known path a puller
* can ask for without already knowing what exists.
*
* Why this file has to exist: the v2 overlay protocol (the canonical client
* surface, what `pgh app install` uses) is content/path addressed and has no
* directory listing — every request needs a path or hash you already know.
* v1's `sendFileList` was orphaned when the unsolicited broadcast was removed
* in hyper-deploy 0.4.1, so `DeployClient.pull()` waits on a message that is
* never sent. This index restores discovery without reviving an unbounded
* broadcast: it is bounded by RELEASE count, not by the 100k-file shared
* dependency cache that broke v1 in the first place.
*
* Hashes are required, not optional: two backends routinely hold the same
* (repo, version) with different bytes, and at least one observed case
* (hardops.zip) had IDENTICAL SIZE with a different hash. A size-only diff
* would silently treat those as equal.
*
* The previous index doubles as the hash cache — an entry whose path, size
* and mtime are unchanged keeps its recorded hash, so a 244 MB artifact is
* hashed once rather than on every publish.
*/
async _writeIndex () {
const prev = new Map()
try {
const raw = JSON.parse(await readFile(join(this._releaseDir, INDEX_FILE), 'utf8'))
for (const f of raw.files || []) prev.set(f.path, f)
} catch {}
const files = []
const repos = await this.list()
for (const r of repos) {
for (const v of r.versions || []) {
if (!v.version || v.incomplete) continue
// manifest.json travels WITH the artifacts. Without it a puller lands
// the bytes but the version reads as `incomplete` on the far side, so
// it never enters that peer's own index and never gets re-served —
// replication would be a star with the publisher at the centre.
// It is a reserved artifact path, hence listed explicitly here.
//
// locations.json is reserved for the same reason and was NOT listed,
// which is why it never replicated despite being copied into the
// version dir expressly so that it would (#18). A mirror then held every
// byte and the vdrive key yet could not resolve a single block. Missing
// files are skipped by the stat() below, so a release with no vdrive is
// unaffected.
const versionFiles = [...(v.artifacts || []).map(a => a.path), ...RESERVED_VERSION_FILES]
for (const apath of versionFiles) {
const rel = r.repo + '/versions/' + v.version + '/' + apath
const abs = join(this._releaseDir, rel)
let st
try { st = await stat(abs) } catch { continue }
const cached = prev.get(rel)
const mtimeMs = Math.floor(st.mtimeMs)
let hash = null
if (cached && cached.size === st.size && cached.mtimeMs === mtimeMs && cached.hash) {
hash = cached.hash
} else {
try { hash = hashChunk(await readFile(abs)) } catch { continue }
}
files.push({ path: rel, size: st.size, mtimeMs, hash, repo: r.repo, version: v.version })
}
}
}
// Channel pointers travel too — a puller that has the bytes but not the
// pointer cannot answer "what is current?".
const channels = []
for (const r of repos) {
for (const [name, c] of Object.entries(r.channels || {})) {
channels.push({ repo: r.repo, channel: name, version: c.version, pinnedAt: c.pinnedAt || null })
}
}
// Make every artifact fetchable BY CONTENT HASH over the V2 overlay. The
// overlay is auto-attached to every hyper-svc swarm connection, so this is
// what lets a peer actually pull the bytes the index advertises — without
// it the index names files nobody can fetch. Namespaced so release
// artifacts cannot collide with the attachment spool sharing this overlay.
//
// BEFORE the index is written, and that ordering is load-bearing. `_rescan`
// appends the release-event that wakes every mirror immediately after this
// returns, and a mirror reacts in milliseconds — so anything the index names
// but the overlay has not been told about yet is answered "no holder". The
// window is real work, not a scheduling hiccup: registerFile chunks and
// hashes the file, which for a 49 MB AppImage is far longer than a mirror
// needs to read the index and start asking.
//
// Measured on stortron 2026-08-17 (ifsrenderer-linux linux-port-14855c7):
// the artifact registered FIRST arrived, and the three after it — including
// a 508-byte manifest.json — all failed with "no holder", stranding the
// version and leaving the channel pinned to the previous release. Size was
// irrelevant; position in this loop was everything.
//
// Only NEW-or-CHANGED entries are registered. _writeIndex runs on every
// mutation, and a mutation happens on every push-triggered build; a forge
// with 558 versions would otherwise issue ~560 overlay index writes per
// publish for content that has not moved. `prev` is the previous index, so
// an unchanged hash means the overlay already knows about it.
if (this._overlay && typeof this._overlay.registerFile === 'function') {
let registered = 0
const failed = []
for (const f of files) {
const before = prev.get(f.path)
if (before && before.hash === f.hash) continue
const overlayPath = RELEASE_OVERLAY_PREFIX + f.path
const diskPath = join(this._releaseDir, f.path)
try {
await this._overlay.registerFile(overlayPath, diskPath,
{ contentHash: f.hash, size: f.size })
registered++
} catch (err) {
// FALL BACK RATHER THAN LOSE THE REGISTRATION. The expensive half of
// registerFile is the eager chunk-map build, which reads the whole
// file — 471 MB for a Windows game bundle — and is explicitly
// optional: without it the file is still servable from the path maps
// and the serve path chunks lazily on first request. Losing the whole
// registration because the optional half failed is what leaves the
// index advertising a file the overlay can never serve.
try {
await this._overlay.registerFile(overlayPath, diskPath,
{ contentHash: f.hash, size: f.size, prepare: false })
registered++
console.warn('[releases] registered ' + f.path + ' WITHOUT a chunk map (' +
(err && err.message ? err.message : err) + ') — first fetch will chunk lazily')
} catch (err2) {
failed.push({ path: f.path, error: err2 && err2.message ? err2.message : String(err2) })
}
}
}
this.lastRegistered = registered
this.lastRegisterFailures = failed
// NOT SWALLOWED. This used to be a bare `catch {}`, and registerFile is the
// ONLY thing that writes the overlay's index — the one that decides what
// this daemon can actually serve. A failure here leaves _index.json (which
// is written unconditionally, below) advertising a file the overlay does
// not know about, so every mirror asks for it forever and is told "no
// holder", with nothing on the publishing side to point at.
if (failed.length) {
console.error('[releases] ' + failed.length + ' artifact(s) FAILED to register with the ' +
'overlay — the index will advertise bytes this daemon cannot serve:')
for (const f of failed) console.error(' ' + f.path + ': ' + f.error)
}
}
const index = { schema: 1, generatedAt: Date.now(), files, channels }
await writeFile(join(this._releaseDir, INDEX_FILE), JSON.stringify(index, null, 2))
return index
}
/**
* Re-point the overlay's in-memory serve maps at our release files.
*
* MUST run at boot or this daemon cannot serve a single release artifact it
* did not publish since the last restart.
*
* hyper-overlay's `_filePaths` / `_contentHashPaths` maps are in-memory and
* populated only by registerFile; FileIndex deliberately persists the
* contentHash but NOT a disk path, because layout belongs to the consumer.
* So after a restart the bytes are still on disk and the chunk map is still in
* the index, but nothing maps hash → file: the content offer is silently
* dropped and the requester just times out. `_writeIndex` cannot repair it
* either — it registers only NEW-or-CHANGED entries, and an artifact whose
* hash has not moved is skipped forever.
*
* Measured 2026-08-08: tabertron held favela-android's 136 MiB APK on disk and
* advertised its exact hash in _index.json, while stortron got "no holder" for
* that hash and for a 436-byte manifest beside it — after three restarts. The
* same failure mode cost an atproto co-owner 75+ hours (hyper-overlay
* seedFilePathsFromIndex docs) and pgh-sidecar a bespoke pre-seed.
*
* Cheap: one index scan, no file reads, no re-hashing.
*
* @returns {Promise<number>} entries seeded
*/
async seedOverlayServeMap () {
if (!this._overlay || typeof this._overlay.seedFilePathsFromIndex !== 'function') return 0
// Collect what the OVERLAY knows while seeding, so the repair below can diff
// it against what the RELEASE INDEX advertises. Two different indexes:
// _index.json says what exists, the overlay's FileIndex says what can be
// served, and only registerFile writes the second one.
const known = new Set()
let unhashed = 0
const seeded = await this._overlay.seedFilePathsFromIndex((overlayPath, entry) => {
if (!overlayPath || !overlayPath.startsWith(RELEASE_OVERLAY_PREFIX)) return null
// ONLY an entry that actually carries a contentHash counts as known.
//
// The overlay persists `contentHash: null` for a file indexed while still
// hashing, and such an entry seeds the PATH map but is unservable BY OID.
// Adding it to `known` on path alone made the repair below skip it, so the
// repair written for exactly this failure could not see it -- and since the
// hash never changes, nothing ever retried. That is the "no holder for a
// file that is on disk with a matching hash" bug: 2026-08-08 (136 MiB APK),
// 2026-08-25 (471 MB zip), 2026-08-29 (168 MB APK).
if (entry && entry.contentHash) known.add(overlayPath)
else if (entry) unhashed++
return join(this._releaseDir, overlayPath.slice(RELEASE_OVERLAY_PREFIX.length))
})
if (unhashed) {
console.log('[releases] overlay index holds ' + unhashed +
' entry(s) with no contentHash - unservable by oid; repairing from the release index')
}
await this._repairOverlayRegistrations(known)
return seeded
}
/**
* Register anything the release index advertises that the overlay does not
* know how to serve.
*
* WHY THIS IS NEEDED AT ALL. _writeIndex registers each artifact and THEN
* writes _index.json — but it wrote the index whether or not the registrations
* succeeded, and the failure was swallowed. So a single failed registerFile
* left the two indexes permanently disagreeing: mirrors read _index.json, ask
* for a hash by content, and are told "no holder" forever. Nothing re-tried,
* because _writeIndex only registers NEW-or-CHANGED entries and the hash never
* changes.
*
* Measured 2026-08-25 (hyper-tps v0.919.0): tabertron held a 471 MB
* hypertps-win64.zip on disk, advertised its exact hash in _index.json, and
* stortron retried "no holder for …dist/hypertps-win64.zip (07af6b4a31a0)"
* indefinitely — while build.log and manifest.json from the SAME publish
* replicated fine. Same peer, same connection, same pass: per-content, not
* per-peer.
*
* CHEAP BY CONSTRUCTION: prepare:false, so no file is read and nothing is
* re-hashed — the content hash is already in the index. It restores the
* ability to serve; the chunk map is rebuilt lazily on first request.
*/
async _repairOverlayRegistrations (known) {
if (!this._overlay || typeof this._overlay.registerFile !== 'function') return 0
let index
try {
index = JSON.parse(await readFile(join(this._releaseDir, INDEX_FILE), 'utf8'))
} catch {
return 0 // no index yet — nothing published on this daemon
}
if (!index || !Array.isArray(index.files)) return 0
let repaired = 0
const failed = []
for (const f of index.files) {
if (!f || !f.path || !f.hash) continue
const overlayPath = RELEASE_OVERLAY_PREFIX + f.path
if (known.has(overlayPath)) continue
try {
// Returns null (not a throw) when the file is gone — a version collected
// by gc is expected here, not an error.
const r = await this._overlay.registerFile(overlayPath, join(this._releaseDir, f.path),
{ contentHash: f.hash, size: f.size, prepare: false })
if (r) repaired++
} catch (err) {
failed.push(f.path + ': ' + (err && err.message ? err.message : err))
}
}
if (repaired) {
console.log('[releases] overlay repair: registered ' + repaired +
' artifact(s) the index advertised but the overlay could not serve')
}
if (failed.length) {
console.error('[releases] overlay repair FAILED for ' + failed.length + ' artifact(s):')
for (const f of failed) console.error(' ' + f)
}
return repaired
}
/** Rebuild _index.json (which re-registers artifacts) and rescan the deploy dir. */
async _reindex () {
try { await this._writeIndex() } catch {}
if (this._deploy && typeof this._deploy.rescan === 'function') {
try { await this._deploy.rescan() } catch {}
}
}
/** Announce one mutation. Non-fatal: a publish must never fail on a notify. */
async _emit (event) {
if (!event || !this._eventLog || !this._eventLog.writable) return
try {
await this._eventLog.append(event)
} catch (err) {
// eslint-disable-next-line no-console
console.error('[release-manager] event append failed:', err && err.message)
}
}
/**
* One mutation: reindex, then announce it.
*
* Index BEFORE the deploy rescan (the deploy server indexes the directory
* during rescan, so an _index.json written afterwards would not be servable
* until the next unrelated mutation), and the event LAST -- so a mirror that
* reacts instantly reads an index already describing the new state.
*
* gc() deliberately does NOT use this: it unlinks every candidate, calls
* _reindex() once, and then _emit()s each tombstone, which preserves the same
* ordering for a fraction of the cost. See the comment in gc().
*/
async _rescan (event = null) {
await this._reindex()
await this._emit(event)
}
}
// Reject path-traversal + anything that'd escape the release dir
function validateName (name, kind) {
if (typeof name !== 'string' || !name) throw new Error(`${kind} must be a non-empty string`)
if (name.includes('/') || name.includes('\\') || name.includes('..') || name.startsWith('.')) {
throw new Error(`invalid ${kind}: ${name}`)
}
}
function validatePath (p) {
if (typeof p !== 'string' || !p) throw new Error('artifact path must be a non-empty string')
if (p.startsWith('/') || p.includes('..')) throw new Error('artifact path must be relative + within version dir: ' + p)
if (p === MANIFEST_FILE) throw new Error(`artifact path cannot be "${MANIFEST_FILE}" — that's reserved`)
}