#!/usr/bin/env bun /** * A product's half of the channels object ctl reads to register it * (`@norskvideo/ctl-product-template-schema/product-channels`). Used by * conventions/build-image.yml, which every product repo copies verbatim: * * guide-gate [] -> `run`, or `skip` * product --template --image * -> the licence product the image is * row --current --product

--image-ref --channel * --image --sha --published-at [--runtime-images ] * [--skipped ] [--run ] * -> the merged channels object * * The opt-out (`.github/publish-gate.json`) exists for a product whose guides * are not reliable yet: it skips only the guide half of the gate, must give a * reason, and the reason travels into the published row, so nobody reads * "passed" off an image whose guides never ran. * * Self-contained on purpose: it imports nothing outside node and this package. * Products resolve the dev-kit's siblings from their OWN lockfiles, so a sibling * import here bound the writer to whatever schema version a product happened to * lock -- the first gated publish in every product died on a missing export. * The object's contract is @norskvideo/ctl-product-template-schema's * product-channels; product-channel.test.ts parses this module's output with it. */ import { existsSync, readFileSync } from "node:fs"; import { builtImageName } from "../licence/check-template.ts"; const CHANNELS = ["nightly", "rc", "latest"] as const; type Channel = (typeof CHANNELS)[number]; export interface ProductChannelRow { image: string; sha: string; publishedAt: string; runtimeImages: string[]; gate: { checks: "passed"; guides: "passed" | { skipped: string }; run?: string }; } interface ProductChannels { schemaVersion: 1; product: string; imageRef: string; updated: string; channels: Partial>; } /** The current object, checked only as far as the merge needs: other channels' * rows are carried through untouched, so a newer writer's fields survive. */ function parseCurrent(text: string): ProductChannels { const raw = JSON.parse(text) as Partial; if (raw.schemaVersion !== 1) throw new Error(`channels object has schemaVersion ${raw.schemaVersion}, not 1`); if (typeof raw.product !== "string" || typeof raw.imageRef !== "string") { throw new Error("channels object has no product/imageRef"); } if (typeof raw.channels !== "object" || raw.channels === null) throw new Error("channels object has no channels"); return raw as ProductChannels; } /** `current` with `channel` set to `row`; every other channel is kept. */ export function withChannelRow( current: ProductChannels | null, opts: { product: string; imageRef: string; channel: Channel; row: ProductChannelRow; updated: string }, ): ProductChannels { if (current && (current.product !== opts.product || current.imageRef !== opts.imageRef)) { throw new Error( `channels object belongs to ${current.product} (${current.imageRef}), not ${opts.product} (${opts.imageRef})`, ); } return { schemaVersion: 1, product: opts.product, imageRef: opts.imageRef, updated: opts.updated, channels: { ...(current?.channels ?? {}), [opts.channel]: opts.row }, }; } function parseChannel(value: string): Channel { if (!(CHANNELS as readonly string[]).includes(value)) throw new Error(`not a channel: ${value}`); return value as Channel; } export type GuideGate = { run: true } | { run: false; reason: string }; /** Whether the guides gate this product's publish, from its opt-out file. */ export function guideGate(optOutJson: string | undefined): GuideGate { if (optOutJson === undefined) return { run: true }; const parsed = JSON.parse(optOutJson) as { guides?: unknown }; const guides = parsed.guides; if (typeof guides !== "object" || guides === null) { throw new Error('publish-gate.json: "guides" must be an object, e.g. {"skip": true, "reason": "..."}'); } const { skip, reason } = guides as { skip?: unknown; reason?: unknown }; if (skip !== true) return { run: true }; if (typeof reason !== "string" || reason.trim() === "") { throw new Error("publish-gate.json: skipping the guides needs a reason"); } return { run: false, reason: reason.trim() }; } /** The licence product whose imageRef is the published image's repo. */ export function productForImage(templateJson: string, image: string): string { const repo = builtImageName({ publishImage: image, buildDefault: "" }); const entries = (JSON.parse(templateJson) as { products?: { product?: string; imageRef?: string }[] }).products ?? []; const match = entries.find((e) => e.imageRef === repo && typeof e.product === "string"); if (!match?.product) throw new Error(`no licence product in licenseTemplate.json names ${repo}`); return match.product; } export function gateRow(opts: { image: string; sha: string; publishedAt: string; runtimeImages: string; guides: GuideGate; run?: string; }): ProductChannelRow { return { image: opts.image, sha: opts.sha, publishedAt: opts.publishedAt, runtimeImages: opts.runtimeImages .split(",") .map((s) => s.trim()) .filter(Boolean), gate: { checks: "passed", guides: opts.guides.run ? "passed" : { skipped: opts.guides.reason }, ...(opts.run ? { run: opts.run } : {}), }, }; } function readIfPresent(path: string | undefined): string | undefined { if (!path || !existsSync(path)) return undefined; const text = readFileSync(path, "utf8"); return text.trim() === "" ? undefined : text; } if (import.meta.main) { const argv = process.argv.slice(2); const [cmd, positional] = argv; const flag = (name: string): string | undefined => { const i = argv.indexOf(`--${name}`); return i === -1 ? undefined : argv[i + 1]; }; const need = (name: string): string => { const v = flag(name); if (!v) { console.error(`product-channel ${cmd}: --${name} is required`); process.exit(2); } return v; }; if (cmd === "guide-gate") { const gate = guideGate(readIfPresent(positional ?? ".github/publish-gate.json")); console.log(gate.run ? "run" : `skip\t${gate.reason}`); } else if (cmd === "product") { console.log(productForImage(readFileSync(need("template"), "utf8"), need("image"))); } else if (cmd === "row") { const currentText = readIfPresent(flag("current")); const current = currentText === undefined ? null : parseCurrent(currentText); const skipped = flag("skipped"); const merged = withChannelRow(current, { product: need("product"), imageRef: need("image-ref"), channel: parseChannel(need("channel")), updated: need("published-at"), row: gateRow({ image: need("image"), sha: need("sha"), publishedAt: need("published-at"), runtimeImages: flag("runtime-images") ?? "", guides: skipped ? { run: false, reason: skipped } : { run: true }, run: flag("run"), }), }); process.stdout.write(`${JSON.stringify(merged, null, 2)}\n`); } else { console.error("usage: product-channel.ts guide-gate [file] | product --template --image | row ..."); process.exit(2); } }