/**
* Per-repo Releases tab — `/repos/:name/releases` (P.5.1).
*
* Shows:
* - Channels section (production / experimental / custom) — each card
* points at a specific version with a "pinned Xh ago" timestamp.
* - All-versions table with promote / delete buttons.
*
* Publishing a release is a write path — performed via RPC (CLI,
* post-push build hook) or the `/api/releases/...` HTTP endpoints. This
* page is management UI for existing releases, not a publish UI.
*/
import { layout, escapeHtml, formatBytes } from '../helpers.js'
import { renderRepoTabs, REPO_TAB_STYLES } from '../components/repo-tabs.js'
const RELEASES_STYLES = `
<style>
/* A registry row whose tree this forge does not hold. Dimmed, not hidden:
the registry genuinely contains it, and another peer may hold the bytes. */
.version-table tr.tree-absent td { opacity: 0.55; }
.channel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; margin: 0.6rem 0 1rem; }
.channel-card { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 0.9rem 1rem; }
.channel-card h3 { margin: 0 0 0.3rem; font-size: 0.75rem; text-transform: uppercase; color: #8b949e; letter-spacing: 0.5px; }
.channel-version { font-size: 1rem; font-weight: 600; color: #58a6ff; font-family: 'SF Mono', monospace; }
.channel-meta { color: #8b949e; font-size: 0.72rem; margin-top: 0.2rem; }
.version-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.version-table th { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid #30363d; color: #8b949e; font-weight: 500; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.5px; }
.version-table td { padding: 0.5rem 0.6rem; border-bottom: 1px solid #21262d; }
.version-table tr:hover td { background: #1c2128; }
.version-pinned { color: #3fb950; font-size: 0.7rem; font-weight: 500; }
.version-stale { color: #484f58; }
.artifact-list { font-family: 'SF Mono', monospace; font-size: 0.7rem; color: #8b949e; }
.vdrive-link { margin-top: 0.35rem; font-family: 'SF Mono', monospace; font-size: 0.68rem; color: #3fb950; cursor: pointer; word-break: break-all; }
.vdrive-link:hover { color: #56d364; }
.vdrive-label { display: inline-block; background: #1c2128; border: 1px solid #30363d; border-radius: 3px; padding: 0 0.3rem; margin-right: 0.3rem; color: #8b949e; font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.5px; }
.artifact-list a { color: #58a6ff; }
</style>`
function timeAgo (ms) {
if (!ms) return ''
const s = Math.floor((Date.now() - ms) / 1000)
if (s < 60) return 'just now'
const m = Math.floor(s / 60)
if (m < 60) return m + 'm ago'
const h = Math.floor(m / 60)
if (h < 24) return h + 'h ago'
const d = Math.floor(h / 24)
if (d < 30) return d + 'd ago'
const mo = Math.floor(d / 30)
if (mo < 12) return mo + 'mo ago'
return Math.floor(mo / 12) + 'y ago'
}
export async function renderRepoReleases (svc, ctx, repoName, opts = {}) {
const prefix = (ctx && ctx.prefix) || ''
if (!svc._releases) {
return errorPage('Release manager not initialised', ctx, repoName)
}
// Note: we intentionally don't gate on sync.info(repoName). Releases
// can exist for repos that aren't currently in sync (imported elsewhere,
// archived, etc.) — the releases view stays useful.
// Tree-backed rows: what `pgh publish` recorded in the pgh ReleaseRegistry.
// Deliberately a SEPARATE section from the file-backed versions below — the
// two namespaces do not correspond (pgh is name@semver, the forge is
// repo/<ref>-<sha7>) and a repo may legitimately have one and not the other.
// Never presented as a join. Best-effort: a sidecar that is down, or older
// than 0.2.51, yields `unavailable` and an explained empty section.
let treeRows = { entries: [], registryKey: null, unavailable: null }
try {
treeRows = await svc.dispatch('tree-registry-list', { name: repoName }, ctx)
} catch (err) {
treeRows = { entries: [], registryKey: null, unavailable: err.message || String(err) }
}
// The two systems disagree on timestamp format: ReleaseManager writes
// `builtAt` as epoch ms (Date.now()), while the pgh registry writes
// `publishedAt` as an ISO STRING ("2026-04-19T18:59:42.885Z"). Feeding the
// string to timeAgo() rendered "NaNy ago", and `(b - a)` on two strings is
// NaN so the sort silently did nothing and rows came out in key order
// (0.1.1, 0.1.10, 0.1.101…). Normalise before doing either.
const toMs = (v) => {
if (v == null) return 0
if (typeof v === 'number') return v
const p = Date.parse(v)
return Number.isNaN(p) ? 0 : p
}
const treeEntries = (treeRows.entries || [])
.map(e => ({ ...e, _ms: toMs(e.publishedAt) }))
.sort((a, b) => b._ms - a._ms)
// Entries this forge cannot serve are OMITTED by default, not just dimmed.
// On hyper-tps 377 of 384 rows were unservable: they buried the 7 usable ones
// and shipped ~280KB of markup to say so. `?all=1` renders them, marked.
const showAll = !!opts.showAll
const absentCount = treeEntries.filter(e => e.heldLocally === false).length
const shownEntries = showAll ? treeEntries : treeEntries.filter(e => e.heldLocally !== false)
const { versions, channels } = await svc._releases.list(repoName)
const pinnedVersions = new Set(Object.values(channels || {}).map(c => c.version))
const channelNames = Object.keys(channels).sort((a, b) => {
// Canonical channel order: production, experimental, anything-else alphabetical
const order = { production: 0, experimental: 1 }
return (order[a] ?? 10) - (order[b] ?? 10) || a.localeCompare(b)
})
const encRepo = encodeURIComponent(repoName)
const base = prefix + '/repos/' + encRepo
const body = `
${REPO_TAB_STYLES}
${RELEASES_STYLES}
${renderRepoTabs(repoName, 'releases', {}, prefix)}
<div class="card">
<h2><a href="${base}">← ${escapeHtml(repoName)}</a> / releases</h2>
</div>
<div class="card">
<h2>Published to pgh</h2>
<div class="meta" style="margin-bottom:0.6rem">
Trees recorded in the pgh release registry by <code>pgh publish</code>. These are
installed with <code>pgh app install</code>, not downloaded — there is no file to
fetch. Separate from the build artifacts below; the two are not the same namespace
and a repo may have either, both, or neither.
</div>
${absentCount > 0
? `<div class="meta" style="margin-bottom:0.6rem">
<strong>${absentCount}</strong> of ${treeEntries.length} entries are not stored on this
forge and are ${showAll ? 'shown below, marked' : 'hidden'}.
The registry is append-only; the tree store is garbage-collected, so older
entries outlive their bytes. Another peer may still hold them.
${showAll
? `<a href="${base}/releases">show only what this forge serves</a>`
: `<a href="${base}/releases?all=1">show all ${treeEntries.length}</a>`}
</div>`
: ''}
${treeRows.unavailable
? `<div class="empty">Registry unavailable — ${escapeHtml(treeRows.unavailable)}</div>`
: shownEntries.length === 0
? `<div class="empty">Nothing published to pgh under the name <code>${escapeHtml(repoName)}</code>.</div>`
: `<table class="version-table">
<thead><tr><th>Version</th><th>Published</th><th>Deps</th><th>Tree</th><th>Install</th></tr></thead>
<tbody>
${shownEntries.map(e => `<tr${e.heldLocally === false ? ' class="tree-absent"' : ''}>
<td><strong>${escapeHtml(String(e.version || ''))}</strong>${e.runtime ? ` <span class="meta">${escapeHtml(e.runtime)}</span>` : ''}${
e.heldLocally === false
? ' <span class="badge badge-closed" title="This forge does not hold this tree. The registry is append-only while the tree store is garbage-collected, so older entries routinely outlive their bytes. Another peer may still hold it.">not stored here</span>'
: ''
}</td>
<td>${e._ms ? timeAgo(e._ms) : '<span class="version-stale">unknown</span>'}</td>
<td class="artifact-list">${e.depCount == null ? '' : escapeHtml(String(e.depCount))}</td>
<td class="artifact-list" title="${escapeHtml(String(e.treeHash || ''))}">${escapeHtml(String(e.treeHash || '').slice(0, 12))}</td>
<td class="artifact-list">${
e.heldLocally === false
? '<span class="meta">unavailable from this forge</span>'
: e.upgrade
? `<code>pgh app install ${escapeHtml(e.upgrade)}@${escapeHtml(String(e.version || ''))}</code>`
: (treeRows.registryKey
? `<code>pgh app install pgh://${escapeHtml(treeRows.registryKey)}/${escapeHtml(String(e.name || repoName))}@${escapeHtml(String(e.version || ''))}</code>`
: '')
}</td>
</tr>`).join('')}
</tbody>
</table>`}
</div>
<div class="card">
<h2>Build artifacts</h2>
<div class="meta" style="margin-bottom:0.6rem">
Files produced by this repo's <code>.pear-git/ci.yaml</code> and served over HTTP.
⚠ These are <strong>node-local</strong> — each daemon serves only what it built
itself, so a peer that did not run the build has nothing here.
</div>
</div>
<div class="card">
<h2>Channels</h2>
${channelNames.length === 0
? '<div class="empty">No channels pinned yet. Publish a release with a channel, or promote an existing version.</div>'
: `<div class="channel-grid">${channelNames.map(name => {
const c = channels[name]
return `
<div class="channel-card">
<h3>${escapeHtml(name)}</h3>
<div class="channel-version">${escapeHtml(c.version)}</div>
<div class="channel-meta">pinned ${timeAgo(c.pinnedAt)}</div>
<form action="${base}/releases/unpin" method="POST" style="margin-top:0.5rem" onsubmit="return confirm('Unpin ${escapeHtml(name)} channel?')">
<input type="hidden" name="channel" value="${escapeHtml(name)}">
<button class="btn-secondary" type="submit" style="font-size:0.68rem;padding:0.15rem 0.45rem" aria-label="Unpin ${escapeHtml(name)} channel">unpin</button>
</form>
</div>`
}).join('')}</div>`}
</div>
<div class="card">
<h2>${versions.length} version${versions.length === 1 ? '' : 's'}</h2>
${versions.length === 0
? '<div class="empty">No releases published yet. Build artifacts land here once the <code>publish-release</code> RPC runs (via CLI, post-push hook, or the HTTP upload endpoint).</div>'
: `<div class="table-scroll"><table class="version-table">
<thead>
<tr><th>Version</th><th>Built</th><th>Size</th><th>Artifacts</th><th style="text-align:right">Actions</th></tr>
</thead>
<tbody>
${versions.map(v => renderVersionRow(v, pinnedVersions, channelNames, base, repoName, prefix)).join('')}
</tbody>
</table></div>`}
</div>
<div class="card">
<h2>Build a release</h2>
<div class="meta" style="margin-bottom:0.6rem">
Runs this repo's <code>.pear-git/ci.yaml</code> against a branch or tag, collects the listed artifacts, and publishes the result. If the ref has channel rules in the yaml (e.g. tags → production), the channel is set automatically — otherwise leave it blank for an unpinned version.
</div>
<form action="${base}/releases/build" method="POST">
<div class="field"><label for="build-ref">Ref (branch or tag)</label><input id="build-ref" name="ref" placeholder="main" required></div>
<label style="display:inline-flex;align-items:center;gap:0.4rem;margin-right:1rem"><input type="checkbox" name="isTag" value="1" style="width:auto"> This ref is a tag</label>
<div class="field"><label for="build-channel">Channel <span class="meta">(optional — overrides yaml rules)</span></label><input id="build-channel" name="channel" placeholder="leave blank to use ci.yaml"></div>
<button type="submit">Build & publish</button>
</form>
</div>
<div class="card">
<h2>Garbage collection</h2>
<div class="meta" style="margin-bottom:0.6rem">Default policy keeps every channel-pinned version + the 3 newest unpinned. Older unpinned versions become GC candidates.</div>
<form action="${base}/releases/gc" method="POST">
<label style="display:inline-flex;align-items:center;gap:0.4rem;margin-right:1rem"><input type="checkbox" name="dryRun" value="1" checked style="width:auto"> Dry run</label>
<button type="submit">Sweep stale versions</button>
</form>
</div>
`
return layout('releases · ' + repoName, '/repos', body, ctx)
}
function renderVersionRow (v, pinned, channels, base, repoName, prefix) {
const isPinned = pinned.has(v.version)
const size = v.sizeBytes != null ? formatBytes(v.sizeBytes) : '—'
const artifacts = Array.isArray(v.artifacts) ? v.artifacts : []
// Each artifact is a direct download link served by GET /releases/:repo/:ver/:path.
// Content-Disposition: attachment is set server-side so browsers download
// rather than trying to render opaque formats like tar.gz.
const dlBase = (prefix || '') + '/releases/' + encodeURIComponent(repoName) + '/' + encodeURIComponent(v.version) + '/'
const artifactLinks = artifacts.length === 0
? '<span class="meta">no files</span>'
: artifacts.map(a => {
const href = dlBase + encodeURIComponent(a.path).replace(/%2F/g, '/')
return `<a href="${href}" download>${escapeHtml(a.path)}</a> <span class="meta" style="margin-right:0.5rem">${formatBytes(a.size)}</span>`
}).join(' · ')
// A virtual Hyperdrive published alongside the artifacts (ci.yaml `vdrive: true`).
// The link is the only durable handle on it — the drive itself is pointers, and
// once the daemon seeds it the store is locked — so surface it here, click to copy.
const vdriveLink = v.vdrive && v.vdrive.link
? `<div class="vdrive-link" title="Click to copy — pear install ${escapeHtml(v.vdrive.link)}"
onclick="navigator.clipboard.writeText('${escapeHtml(v.vdrive.link)}')">
<span class="vdrive-label">pear</span> ${escapeHtml(v.vdrive.link)}
</div>`
: ''
const promoteBtn = `
<form action="${base}/releases/promote" method="POST" style="display:inline;margin:0">
<input type="hidden" name="version" value="${escapeHtml(v.version)}">
<input name="toChannel" placeholder="channel" required style="width:110px;padding:0.2rem 0.4rem;font-size:0.7rem;display:inline-block;margin-right:0.2rem">
<button class="btn-secondary" type="submit" style="font-size:0.68rem;padding:0.15rem 0.45rem">promote</button>
</form>`
const deleteBtn = isPinned
? `<span class="meta" title="Unpin the channel first">pinned</span>`
: `<form action="${base}/releases/delete" method="POST" style="display:inline;margin:0" onsubmit="return confirm('Delete version ${escapeHtml(v.version)}? This cannot be undone.')">
<input type="hidden" name="version" value="${escapeHtml(v.version)}">
<button class="btn-secondary" type="submit" style="font-size:0.68rem;padding:0.15rem 0.45rem" aria-label="Delete version ${escapeHtml(v.version)}">delete</button>
</form>`
return `
<tr${!isPinned ? ' class="version-stale"' : ''}>
<td><span class="channel-version" style="font-size:0.85rem">${escapeHtml(v.version)}</span> ${isPinned ? '<span class="version-pinned">pinned</span>' : ''}</td>
<td>${timeAgo(v.builtAt)}</td>
<td>${size}</td>
<td class="artifact-list">${artifactLinks}${vdriveLink}</td>
<td style="text-align:right;white-space:nowrap">${promoteBtn} ${deleteBtn}</td>
</tr>`
}
function errorPage (msg, ctx, repoName) {
const body = `
${REPO_TAB_STYLES}
${repoName ? renderRepoTabs(repoName, 'releases', {}, prefix) : ''}
<div class="card"><h2>Error</h2><div class="empty">${escapeHtml(msg)}</div></div>`
return layout('Error', '/repos', body, ctx)
}