/** * A fingerprint of the celilo source that `cele2e build-infra` bakes INTO * `celilo-e2e/management:latest`, so a run can tell whether the image it is * about to use was built from the tree it is testing. * * This exists because of a specific, measured hazard. The management image * carries a real installed `celilo` CLI, produced by running install.sh against * the simulated npm registry, which serves tarballs packed from THIS workspace. * That CLI is the artifact under test. Nothing about it announces its age: a * run against a month-old image looks exactly like a run against a fresh one, * passes or fails for reasons that have nothing to do with the working tree, * and sends the reader to the wrong file. * * The defence is to measure rather than to remember. build-infra stamps the * fingerprint of what it packed onto the image as a label; `cele2e doctor` * recomputes it from the working tree and says so when the two differ. That is * a warning, never a refusal — an operator deliberately testing an older image * is doing something legitimate, and should be told rather than stopped. * * Deliberately NOT covered: `packages/e2e` itself. The harness runs from the * host worktree on every invocation (`bun` executes the checkout), so its * source is never stale by construction, and including it would make the * warning fire continuously for anyone working on the rig — the fastest way to * teach people to ignore it. * * For the same reason the fingerprint is CONTENT, not commit. This machine runs * seven agent worktrees on seven branches against one Docker daemon and * therefore one baked image, and most of those branches do not touch the CLI at * all. Hashing `HEAD` would make every one of them disagree with the image on * the strength of an unrelated commit, and a warning that is usually wrong is * one nobody reads. Hashing the bytes means a branch that changed no baked * source agrees with the image, which is exactly what is true of it. */ import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; /** * Docker label carrying the fingerprint. Reverse-DNS so it cannot collide with * a label from a base image. */ export const SOURCE_LABEL = 'computer.celilo.e2e.source'; /** * Value stamped by `build-infra --published`, whose CLI comes from real npm * rather than from this tree. There is no source to be stale against, so the * freshness check reports the mode and stops. */ export const PUBLISHED_FINGERPRINT_PREFIX = 'published:'; /** * Value stamped by a CONSUMER-mode bake: `cele2e build-infra` run from an * installed `@celilo/e2e` with no monorepo checkout. The CLI comes from the sim * registry's freshly staged tarballs, so it is neither real npm nor this tree, * and no tree fingerprint exists to record. The version is the honest * attribution. Deliberately NOT the published prefix: that one tells the * freshness check there is nothing to be stale against, which would suppress a * real warning if such an image were later inspected from a checkout. */ export const CONSUMER_FINGERPRINT_PREFIX = 'consumer:'; /** * Repo-relative paths whose content reaches the baked CLI. * * Derived from the tree rather than listed, so a new `packages/` is covered * the day it is added — the failure mode of a hand-written list is that it * covers what someone remembered, not what the repo contains (celilo#582). */ export function bakedSourcePaths(repoRoot: string): string[] { const paths = ['apps/celilo/src', 'apps/celilo/drizzle', 'apps/celilo/package.json']; const packagesDir = join(repoRoot, 'packages'); if (existsSync(packagesDir)) { for (const name of readdirSync(packagesDir).sort()) { // See the header: the harness runs from the worktree, never from the image. if (name === 'e2e') continue; if (!statSync(join(packagesDir, name)).isDirectory()) continue; for (const sub of ['src', 'package.json']) { if (existsSync(join(packagesDir, name, sub))) paths.push(`packages/${name}/${sub}`); } } } // install.sh is the script the bake actually executes; a change to it changes // what lands in the image even when no package source moved. const installSh = 'modules/celilo-website/site/public/install.sh'; if (existsSync(join(repoRoot, installSh))) paths.push(installSh); return paths; } /** One deviation of the working tree from the index, as `git status --porcelain` reports it. */ export interface WorkingTreeEntry { /** Two-character porcelain status, e.g. ' M', '??', 'D '. */ status: string; path: string; /** sha256 of the file's current content, or '' when it no longer exists. */ contentHash: string; } /** A path and the hash of its current content. */ export interface FileHash { path: string; hash: string; } /** * The fingerprint: every baked file paired with the hash of its bytes. * * Pure, so the composition is testable without a repo. Two checkouts holding * the same baked source fingerprint identically no matter what branch, commit * or edit history produced it; changing one byte of one baked file changes it. * Entries are sorted here rather than trusted from the caller, so git's output * order cannot change the answer. */ export function fingerprintFrom(files: FileHash[]): string { const hash = createHash('sha256'); for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) { hash.update(`${file.path} ${file.hash}\n`); } return hash.digest('hex').slice(0, 16); } function git(repoRoot: string, args: string[]): string { return execFileSync('git', ['-C', repoRoot, ...args], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15_000, }); } /** * Read the working tree's deviations from HEAD across the baked paths. * * `--porcelain=v1 -z` is used because a path with a space in it is otherwise * ambiguous, and `-uall` so an untracked FILE inside a tracked directory is * listed individually rather than collapsed to its directory. */ export function readWorkingTreeEntries(repoRoot: string, paths: string[]): WorkingTreeEntry[] { const raw = git(repoRoot, ['status', '--porcelain=v1', '-z', '-uall', '--', ...paths]); const entries: WorkingTreeEntry[] = []; for (const record of raw.split('\0')) { if (record.length < 4) continue; const status = record.slice(0, 2); const path = record.slice(3); const abs = join(repoRoot, path); let contentHash = ''; try { if (existsSync(abs) && statSync(abs).isFile()) { contentHash = createHash('sha256').update(readFileSync(abs)).digest('hex').slice(0, 16); } } catch { // Unreadable is a deviation in itself; the status char still records it. } entries.push({ status, path, contentHash }); } return entries; } /** * Every tracked file under `paths`, paired with git's own blob hash for it. * * `git ls-files -s` reports the index, which is content-addressed and free — * git has already hashed these. It is the working tree for every file that has * not been edited since it was staged, and `readWorkingTreeEntries` corrects * the rest. */ export function readTrackedBlobs(repoRoot: string, paths: string[]): FileHash[] { const raw = git(repoRoot, ['ls-files', '-s', '-z', '--', ...paths]); const files: FileHash[] = []; for (const record of raw.split('\0')) { if (!record) continue; // ` \t` const [meta, path] = record.split('\t'); const blob = meta?.split(/\s+/)[1]; if (blob && path) files.push({ path, hash: blob }); } return files; } /** * Fold the working tree's deviations over the indexed blobs: an edited or * untracked file contributes the hash of what is on disk now, a deleted one * contributes nothing. * * Pure, so the precedence is testable without a repo. */ export function applyWorkingTree(tracked: FileHash[], entries: WorkingTreeEntry[]): FileHash[] { const byPath = new Map(tracked.map((f) => [f.path, f.hash])); for (const entry of entries) { if (entry.contentHash === '') byPath.delete(entry.path); else byPath.set(entry.path, entry.contentHash); } return [...byPath].map(([path, hash]) => ({ path, hash })); } /** * The current tree's fingerprint, or null when there is no repo to read — an * npm-installed consumer has no celilo checkout, and has nothing to be stale * against. */ export function computeSourceFingerprint(repoRoot: string | undefined): string | null { if (!repoRoot || !existsSync(join(repoRoot, 'apps', 'celilo', 'package.json'))) return null; try { const paths = bakedSourcePaths(repoRoot); return fingerprintFrom( applyWorkingTree(readTrackedBlobs(repoRoot, paths), readWorkingTreeEntries(repoRoot, paths)), ); } catch { // Not a git checkout (a tarball extraction, say). Nothing to compare. return null; } }