1// Repo sites. A deploy is a commit on refs/sites/<name> whose tree is the2// built output, so git provides the content-addressed store, dedup across3// deploys, history, and revert. Review previews live under4// refs/site-previews/<name>/<branch>. The post-receive hook extracts trees5// into the web root; this module only reads metadata for the pages.6import fs from 'node:fs';7import { tryRun } from './git.ts';8import type { RepoCache } from './cache.ts';9import type { Preview, Ref, RepoUrls, Site, SiteDeploy } from './types.ts';1011const FMT = ['%H', '%T', '%cI', '%s', '%(trailers:key=Source-Commit,valueonly,separator=%x2c)', '%(trailers:key=Built-By,valueonly,separator=%x2c)'].join('%x1f');1213function history(gitDir: string, ref: string, n: number): SiteDeploy[] {14 const out = tryRun(gitDir, ['log', '--first-parent', `-n${n}`, `--format=${FMT}%x1e`, ref]);15 if (!out) return [];16 return out.split('\x1e').map((s) => s.trim()).filter(Boolean).map((rec) => {17 const [sha, tree, date, subject, source, builtBy] = rec.split('\x1f');18 return { sha, tree, date, subject, source: source?.trim() || null, builtBy: builtBy?.trim() || null };19 });20}2122// The allowed signers file, if there is one; remembered signature checks23// are dropped when it changes.24export function signersFor(allowedSigners: string, cache: RepoCache): string | null {25 const signers = fs.existsSync(allowedSigners) ? allowedSigners : null;26 cache.useSigners(signers ? `${signers}:${fs.statSync(signers).mtimeMs}` : 'none');27 return signers;28}2930// Signature checks run ssh-keygen per commit; a deploy commit never changes,31// so each is checked once and remembered (until the allowed signers change).32function checkSignatures(gitDir: string, deploys: SiteDeploy[], allowedSigners: string, cache: RepoCache): void {33 const signers = signersFor(allowedSigners, cache);34 const missing = [...new Set(deploys.map((c) => c.sha))].filter((sha) => !cache.sigs[sha]);35 if (missing.length && signers) {36 const out = tryRun(gitDir, ['-c', `gpg.ssh.allowedSignersFile=${signers}`, 'log', '--no-walk=unsorted', '--stdin', '--format=%H%x1f%G?%x1f%GS'], { input: missing.join('\n') + '\n' }) ?? '';37 for (const line of out.split('\n').filter(Boolean)) {38 const [sha, sig, signer] = line.split('\x1f');39 cache.sigs[sha] = { signed: sig === 'G' ? 'good' : sig === 'N' ? null : sig === 'B' ? 'bad' : 'unknown', signer: signer || null };40 }41 }42 for (const c of deploys) Object.assign(c, cache.sigs[c.sha] ?? { signed: null, signer: null });43}4445interface LoadOptions {46 urls: RepoUrls;47 allowedSigners: string;48 cache: RepoCache;49}5051export function loadSites(gitDir: string, refs: Ref[], { urls, allowedSigners, cache }: LoadOptions): { sites: Site[]; previews: Preview[] } {52 const sites: Site[] = [];53 const previews: Preview[] = [];54 for (const r of refs) {55 if (r.name.startsWith('refs/sites/')) {56 const name = r.name.slice('refs/sites/'.length);57 sites.push({ name, url: urls.site(name), history: history(gitDir, r.name, 10) });58 } else if (r.name.startsWith('refs/site-previews/')) {59 const rest = r.name.slice('refs/site-previews/'.length);60 const slash = rest.indexOf('/');61 if (slash < 0) continue;62 const name = rest.slice(0, slash);63 const branch = rest.slice(slash + 1);64 previews.push({ name, branch, url: urls.preview(name, branch), history: history(gitDir, r.name, 1) });65 }66 }67 checkSignatures(gitDir, [...sites, ...previews].flatMap((s) => s.history), allowedSigners, cache);68 return { sites, previews };69}