// Lint a repo's licenceTemplate.json against what the repo actually is. // // The template is this repo's half of a licence: the `products[]` entries a // grant needs in order to entitle it. Minting composes {a grant's header} + // {the union of one or more repos' entries}, so a template that names the wrong // product — or an imageRef the build never produces — mints a licence that // entitles nothing, and the first sign is a 60-second readiness timeout. // // That is not hypothetical. licensing/partner-all-products-template.json is // hand-maintained, names four products, and has silently omitted both turnkeys // for as long as they have existed. This lint is what makes the derived // version impossible to get wrong the same way. // // It asserts recorded INTENT matches reality, not security: a user can retag // any image, so imageRef buys no defence. What it buys is a statement of what // was meant, which is only worth having if something keeps it true. import { readFileSync } from "node:fs"; export interface RepoFacts { /** The repo's own PRODUCT_NAME. */ product: string; /** The image name CI builds under — see builtImageName. */ image: string; } /** The image name CI builds under, by CI's own rule: PUBLISH_IMAGE when set, * else the build script's IMAGE_TAG default, tag stripped. The licence matches * on the repo part alone (`imageRefMatches`: `parsed.repo !== entry.imageRef`), * so the tag never belongs in a template. */ export function builtImageName(opts: { publishImage: string; buildDefault: string }): string | null { const ref = opts.publishImage.trim() || opts.buildDefault.trim(); if (!ref) return null; // Strip a tag, but not a registry port: a colon is a tag only after the last slash. const lastSlash = ref.lastIndexOf("/"); const colon = ref.indexOf(":", lastSlash + 1); return colon === -1 ? ref : ref.slice(0, colon); } const REQUIRED_ENTRY_FIELDS = ["product", "imageRef", "versionConstraint", "mode"] as const; /** Fields that describe WHO a licence was cut for. A repo does not know them, * and a merged staff licence that inherited one repo's expiry would be a * quiet, dated surprise. */ const GRANT_ONLY_FIELDS = ["issuedTo", "email", "expiry", "timeout", "nonProduction"] as const; export function templateProblems(templateJson: string, repo: RepoFacts): string[] { let parsed: { products?: unknown } & Record; try { parsed = JSON.parse(templateJson); } catch (e) { return [`licenseTemplate.json is not valid JSON: ${String(e)}`]; } if (typeof parsed !== "object" || parsed === null) return ["licenseTemplate.json is not an object"]; const problems: string[] = []; for (const f of GRANT_ONLY_FIELDS) { if (f in parsed) { problems.push(`licenseTemplate.json carries "${f}", which belongs to the grant, not the repo — remove it`); } } if (!Array.isArray(parsed.products)) { problems.push('licenseTemplate.json has no "products" array'); return problems; } const entries = parsed.products as Record[]; for (const [i, entry] of entries.entries()) { for (const f of REQUIRED_ENTRY_FIELDS) { if (typeof entry?.[f] !== "string") problems.push(`products[${i}] is missing "${f}"`); } const ref = entry?.imageRef; if (typeof ref === "string") { const lastSlash = ref.lastIndexOf("/"); if (ref.indexOf(":", lastSlash + 1) !== -1) { problems.push(`products[${i}].imageRef "${ref}" carries a tag; a licence matches the repo part only`); } } } const named = entries.map((e) => e?.product).filter((p): p is string => typeof p === "string"); if (!named.includes(repo.product)) { problems.push( `licenseTemplate.json does not name this repo's product ${repo.product} (names: ${named.join(", ") || "nothing"})`, ); return problems; } const mine = entries.find((e) => e?.product === repo.product); if (typeof mine?.imageRef === "string" && mine.imageRef !== repo.image) { problems.push(`products[].imageRef for ${repo.product} is "${mine.imageRef}" but this repo builds "${repo.image}"`); } return problems; } if (import.meta.main) { const [file, product, image] = process.argv.slice(2); if (!file || !product || !image) { console.error("usage: check-template.ts "); process.exit(2); } let contents: string; try { contents = readFileSync(file, "utf8"); } catch { // Every fleet repo carries one and create-product emits one, so absence is // a repo that has stopped saying which licence it needs. console.error(`licence-template: no ${file} — declare the product and image this repo's licence must entitle`); process.exit(1); } const problems = templateProblems(contents, { product, image }); if (problems.length) { for (const p of problems) console.error(`licence-template: ${p}`); process.exit(1); } console.log(`licence-template: ${file} names ${product} at ${image}`); }