/**
* Release RPC handlers (P.5.1).
*
* Thin wrappers over svc._releases (ReleaseManager). All mutating handlers
* require writer; reads are public. This module handles the per-repo
* version + channel story — not to be confused with the lower-level
* deploy-service handlers in ../services/deploy.js which manage the
* hyper-deploy DeployServer itself.
*/
import { readFile } from '#node:fs/promises'
import { join } from '#node:path'
import { requireWriter } from '../auth/check.js'
import { buildAndPublish } from '../ci-orchestrator.js'
export function releaseHandlers (svc) {
const registry = () => svc.identityRegistry
const rm = () => {
if (!svc._releases) throw new Error('release manager not initialised')
return svc._releases
}
return [
{
name: 'list-repo-releases',
fn: async (ctx, params = {}) => {
if (!params.repo) throw new Error('list-repo-releases requires { repo }')
return rm().list(params.repo)
}
},
{
name: 'list-all-releases',
fn: async () => ({ repos: await rm().list() })
},
{
// The discovery surface a ReleasePuller needs, and the ONLY thing the v2
// overlay cannot provide for itself: it is content/path addressed with no
// directory listing, so a peer must be told what exists before it can ask
// for anything. Returns `<releaseDir>/_index.json` — files with size +
// content hash, plus channel pointers.
//
// Deliberately NOT the v1 `sendFileList` broadcast that hyper-deploy 0.4.1
// removed: this is solicited, travels on the authenticated hyper-svc
// channel, and is bounded by RELEASE count rather than by the 100k-file
// shared dependency cache that blew the 16 MiB frame limit.
//
// Regenerated lazily if absent so an origin that has not published since
// upgrading still answers.
// ANONYMOUS-CALLABLE, because a puller reaches its origin over swarm-RPC
// and a swarm ctx carries trust:'network' — the origin would otherwise
// refuse its own mirror. Exactly the trap that already required dump-keys
// and list-repos to be opted in for resync-from-origin.
//
// Filtered, not open: an anonymous caller sees only repos it could read
// anyway, so a private repo's versions and hashes do not leak. A trusted
// (UDS / writer) caller gets everything.
name: 'release-index',
fn: async (ctx) => {
const m = rm()
let index
try {
index = JSON.parse(await readFile(join(m._releaseDir, '_index.json'), 'utf8'))
} catch {
index = await m._writeIndex()
}
const anon = !ctx || !ctx.identity
if (!anon || !svc.repoAccess) return index
const allowed = new Map()
const mayRead = async (repo) => {
if (allowed.has(repo)) return allowed.get(repo)
let ok = true
try {
const d = await svc.repoAccess.async(repo, { transport: 'swarm-rpc', identity: null })
if (d && d.wouldDeny && d.enforced) ok = false
} catch { ok = true }
allowed.set(repo, ok)
return ok
}
const files = []
for (const f of index.files || []) {
if (f && f.repo && await mayRead(f.repo)) files.push(f)
}
const channels = []
for (const c of index.channels || []) {
if (c && c.repo && await mayRead(c.repo)) channels.push(c)
}
return { ...index, files, channels, filtered: true }
}
},
{
// Pull release artifacts from this daemon's ORIGIN, now, without waiting
// for the scheduler's interval. Writer-gated: it rewrites bytes on disk.
// `{ dryRun: true }` reports the plan and touches nothing — use it first.
//
// Returns { ok:false } rather than throwing when this daemon is not a
// mirror or the puller is disabled, so an operator gets a reason instead
// of a stack trace.
name: 'pull-releases',
fn: async (ctx, params = {}) => {
await requireWriter(ctx, registry())
if (!svc._releasePull || typeof svc._releasePull.pullNow !== 'function') {
return {
ok: false,
reason: 'release pull is not enabled on this daemon (set PGV3_RELEASE_PULL=1, ' +
'and note it only pulls from an origin — a primary has none)'
}
}
// `repos` scopes the reconcile to named repos, the same way an event-driven
// reconcile scopes itself. It was missing here, so operator-issued pulls
// silently ran against the WHOLE index while appearing scoped — the logs
// showed no scope tag and the diff covered every release ever published.
return svc._releasePull.pullNow({
dryRun: !!params.dryRun,
repos: Array.isArray(params.repos) ? params.repos.filter(r => typeof r === 'string') : undefined
})
}
},
{
name: 'get-release-manifest',
fn: async (ctx, params = {}) => {
if (!params.repo || !params.version) {
throw new Error('get-release-manifest requires { repo, version }')
}
const manifest = await rm().getManifest(params.repo, params.version)
if (!manifest) throw new Error(`release ${params.repo}@${params.version} not found`)
return manifest
}
},
{
// Inline-data variant: caller ships the whole payload in-RPC. Fine for
// small artifacts (metadata + small tarballs). For big binaries, use
// the HTTP streaming endpoint instead (/api/releases/upload/...).
name: 'publish-release',
fn: async (ctx, params = {}) => {
await requireWriter(ctx, registry())
const { repo, version, channel, artifacts, meta } = params
if (!repo || !version) throw new Error('publish-release requires { repo, version, artifacts }')
if (!Array.isArray(artifacts) || artifacts.length === 0) {
throw new Error('publish-release requires non-empty artifacts array')
}
// artifacts may arrive as [{ path, data: base64 }] over JSON-RPC.
// Decode lazily so the ReleaseManager always sees Buffers.
const decoded = artifacts.map(a => ({
path: a.path,
data: typeof a.data === 'string' && !Buffer.isBuffer(a.data)
? Buffer.from(a.data, 'base64')
: a.data
}))
return rm().publish({ repo, version, channel, artifacts: decoded, meta })
}
},
{
name: 'promote-release',
fn: async (ctx, params = {}) => {
await requireWriter(ctx, registry())
const { repo, version, toChannel } = params
if (!repo || !version || !toChannel) {
throw new Error('promote-release requires { repo, version, toChannel }')
}
return rm().promote(repo, version, toChannel)
}
},
{
name: 'unpin-release-channel',
fn: async (ctx, params = {}) => {
await requireWriter(ctx, registry())
const { repo, channel } = params
if (!repo || !channel) throw new Error('unpin-release-channel requires { repo, channel }')
return rm().unpinChannel(repo, channel)
}
},
{
name: 'delete-release',
fn: async (ctx, params = {}) => {
await requireWriter(ctx, registry())
const { repo, version } = params
if (!repo || !version) throw new Error('delete-release requires { repo, version }')
return rm().deleteVersion(repo, version)
}
},
{
name: 'gc-releases',
fn: async (ctx, params = {}) => {
await requireWriter(ctx, registry())
return rm().gc({
repo: params.repo,
keepN: typeof params.keepN === 'number' ? params.keepN : undefined,
dryRun: !!params.dryRun
})
}
},
{
// What virtual Hyperdrives is this daemon serving, and under what links.
//
// NOT in ANONYMOUS_METHODS (pear-git.js), so it needs a trusted caller —
// UDS operator or a writer. The links themselves are public keys and grant
// nothing on their own, but enumerating every repo's drive would hand an
// anonymous caller a map of what this daemon publishes, which the repo
// listing already gates.
name: 'vdrive-status',
fn: async (ctx, params = {}) => {
await requireWriter(ctx, registry())
const seeder = svc._vdriveSeeder
if (!seeder) return { enabled: false, drives: [] }
const drives = seeder.stats()
if (params.repo) {
return { enabled: seeder.enabled, drives: drives.filter((d) => d.repo === params.repo) }
}
return { enabled: seeder.enabled, drives }
}
},
{
// Run the repo's .pear-git/ci.yaml against a ref, collect artifacts,
// publish the resulting release. Returns { ok, version, channel,
// steps, buildLog } — buildLog is capped at ~1MB of step stdout.
name: 'build-release',
fn: async (ctx, params = {}) => {
await requireWriter(ctx, registry())
if (!svc._sync) throw new Error('sync service not running')
let { repo, ref, isTag, version, channel } = params
if (!repo || !ref) throw new Error('build-release requires { repo, ref }')
// Normalize fully-qualified refs ("refs/heads/master", "refs/tags/v1")
// to bare names. autoBuildFromPush pre-strips before reaching
// buildAndPublish; RPC callers (CLI, HTTP) pass full refs, so mirror
// that normalization here. Without this, deriveVersion produces
// versions like "refs/heads/master-abc1234" with embedded slashes
// that break releaseManager's filesystem layout.
if (ref.startsWith('refs/tags/')) {
if (isTag === undefined) isTag = true
ref = ref.slice('refs/tags/'.length)
} else if (ref.startsWith('refs/heads/')) {
if (isTag === undefined) isTag = false
ref = ref.slice('refs/heads/'.length)
}
return buildAndPublish({
sync: svc._sync,
releaseManager: rm(),
// Manual triggers need the seeder too: without it a vdrive publish
// cannot drop the seeder's exclusive lock on the store, and the new
// drive is never announced. The push path threads this already.
vdriveSeeder: svc._vdriveSeeder,
vdriveRootKey: svc._vdriveRootKey || null,
repo,
ref,
isTag: !!isTag,
version,
channel
})
}
}
]
}