OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / web / pages / repo-releases.js

294 lines · javascript ref:
/**
 * 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}">&larr; ${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 &amp; 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)
}