diff --git a/tools/check-template-deps.ts b/tools/check-template-deps.ts index f6f1811ec..49aad7080 100644 --- a/tools/check-template-deps.ts +++ b/tools/check-template-deps.ts @@ -22,6 +22,8 @@ import { join } from "node:path"; import { parse as parseYaml } from "yaml"; +import { verifyLockVersions } from "./check-template-lock-versions"; + const ROOT = join(import.meta.dirname, ".."); const templatePkg = JSON.parse( @@ -135,6 +137,31 @@ if (astGrepVersion && astGrepLinuxVersion) { ); } +// Lock parity check: both committed lockfiles must resolve @databricks/appkit +// and @databricks/appkit-ui to the versions pinned in template/package.json. +// Guards against one lockfile drifting from package.json (the Phase-1 bug). +const APPKIT_PACKAGES = ["@databricks/appkit", "@databricks/appkit-ui"]; +const expectedLockVersions: Record = {}; +for (const pkg of APPKIT_PACKAGES) { + if (templatePkg.dependencies?.[pkg]) { + expectedLockVersions[pkg] = templatePkg.dependencies[pkg]; + } +} +const { mismatches } = verifyLockVersions( + [ + join(ROOT, "template/package-lock.json"), + join(ROOT, "template/pnpm-lock.yaml"), + ], + expectedLockVersions, +); +for (const m of mismatches) { + errors.push( + `Lock version drift in ${m.lockfile}: "${m.package}" resolves to ` + + `${m.found ?? ""} but template/package.json pins ${m.expected}. ` + + `Regenerate the lockfile so scaffolded apps install the pinned SDK version.`, + ); +} + if (errors.length) { for (const e of errors) console.error(e); process.exit(1); diff --git a/tools/check-template-lock-versions.test.ts b/tools/check-template-lock-versions.test.ts new file mode 100644 index 000000000..34ba60599 --- /dev/null +++ b/tools/check-template-lock-versions.test.ts @@ -0,0 +1,116 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, test } from "vitest"; +import { stringify } from "yaml"; + +import { verifyLockVersions } from "./check-template-lock-versions"; + +const directories: string[] = []; +type Format = "npm" | "pnpm"; + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +// Builds a lockfile fixture resolving each package to the given version. pnpm +// versions carry a peer suffix the verifier must strip. +function writeLock(format: Format, versions: Record): string { + const directory = mkdtempSync(join(tmpdir(), "appkit-lock-versions-test-")); + directories.push(directory); + const path = join( + directory, + format === "npm" ? "package-lock.json" : "pnpm-lock.yaml", + ); + if (format === "npm") { + const packages: Record = { "": { name: "template" } }; + for (const [pkg, version] of Object.entries(versions)) { + packages[`node_modules/${pkg}`] = { version }; + } + writeFileSync(path, JSON.stringify({ lockfileVersion: 3, packages })); + } else { + const dependencies: Record = {}; + for (const [pkg, version] of Object.entries(versions)) { + dependencies[pkg] = { + specifier: version, + version: `${version}(react@19.2.4)`, + }; + } + writeFileSync( + path, + stringify({ + lockfileVersion: "9.0", + importers: { ".": { dependencies } }, + }), + ); + } + return path; +} + +const EXPECTED = { + "@databricks/appkit": "0.80.0", + "@databricks/appkit-ui": "0.80.0", +}; + +describe.each(["npm", "pnpm"] as const)("%s lock", (format) => { + test("passes when both packages resolve the expected version", () => { + const lock = writeLock(format, EXPECTED); + const result = verifyLockVersions([lock], EXPECTED); + expect(result.ok).toBe(true); + expect(result.mismatches).toEqual([]); + }); + + test.each(["@databricks/appkit", "@databricks/appkit-ui"])( + "reports a mismatch when %s is pinned to an old version", + (stale) => { + const lock = writeLock(format, { ...EXPECTED, [stale]: "0.76.1" }); + const result = verifyLockVersions([lock], EXPECTED); + expect(result.ok).toBe(false); + expect(result.mismatches).toEqual([ + { lockfile: lock, package: stale, found: "0.76.1", expected: "0.80.0" }, + ]); + }, + ); + + test("reports a mismatch when a package is missing from the lockfile", () => { + const lock = writeLock(format, { "@databricks/appkit": "0.80.0" }); + const result = verifyLockVersions([lock], EXPECTED); + expect(result.ok).toBe(false); + expect(result.mismatches).toEqual([ + { + lockfile: lock, + package: "@databricks/appkit-ui", + found: null, + expected: "0.80.0", + }, + ]); + }); +}); + +test("catches the Phase-1 regression: pnpm lock stale while package.json is new", () => { + // npm lock regenerated to the new version, pnpm lock left on the old one. + const npmLock = writeLock("npm", EXPECTED); + const pnpmLock = writeLock("pnpm", { + "@databricks/appkit": "0.76.1", + "@databricks/appkit-ui": "0.76.1", + }); + const result = verifyLockVersions([npmLock, pnpmLock], EXPECTED); + expect(result.ok).toBe(false); + expect(result.mismatches).toEqual([ + { + lockfile: pnpmLock, + package: "@databricks/appkit", + found: "0.76.1", + expected: "0.80.0", + }, + { + lockfile: pnpmLock, + package: "@databricks/appkit-ui", + found: "0.76.1", + expected: "0.80.0", + }, + ]); +}); diff --git a/tools/check-template-lock-versions.ts b/tools/check-template-lock-versions.ts new file mode 100644 index 000000000..72e3ebf1b --- /dev/null +++ b/tools/check-template-lock-versions.ts @@ -0,0 +1,171 @@ +#!/usr/bin/env tsx +/** + * Verifies that the template lockfiles resolve @databricks/appkit and + * @databricks/appkit-ui to the version pinned in template/package.json. + * + * The template ships BOTH lockfiles (package-lock.json + pnpm-lock.yaml). The + * release pipeline regenerates both on every version bump; if one is skipped, + * scaffolded apps install an SDK version that mismatches package.json — the + * exact Phase-1 regression. This parity check fails closed before that ships, + * both in PR CI (via check-template-deps.ts) and at release time (via + * publish-template-tag.ts). + * + * Usage: + * tsx tools/check-template-lock-versions.ts [lockfile...] [--version ] + * + * lockfile Lockfile paths (relative to repo root or absolute). Defaults to + * both committed template locks. Format detected by filename. + * --version Expected version for both @databricks packages. Defaults to the + * versions pinned in template/package.json dependencies. + */ + +import { readFileSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +import { parse as parseYaml } from "yaml"; + +const ROOT = join(import.meta.dirname, ".."); +const VERIFIED_PACKAGES = ["@databricks/appkit", "@databricks/appkit-ui"]; + +export interface VersionMismatch { + lockfile: string; + package: string; + found: string | null; + expected: string; +} + +export interface VerifyResult { + ok: boolean; + mismatches: VersionMismatch[]; +} + +/** + * Reads the resolved version of each `expected` package from each lockfile and + * flags any that differ (or are missing). Pure: reads files, returns a report. + */ +export function verifyLockVersions( + lockPaths: string[], + expected: Record, +): VerifyResult { + const mismatches: VersionMismatch[] = []; + for (const lockPath of lockPaths) { + const resolved = readResolvedVersions(lockPath); + for (const [pkg, want] of Object.entries(expected)) { + const found = resolved[pkg] ?? null; + if (found !== want) { + mismatches.push({ + lockfile: lockPath, + package: pkg, + found, + expected: want, + }); + } + } + } + return { ok: mismatches.length === 0, mismatches }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Resolved versions keyed by package name, dispatched by lockfile format. */ +function readResolvedVersions(lockPath: string): Record { + const content = readFileSync(lockPath, "utf-8"); + if (lockPath.endsWith(".yaml") || lockPath.endsWith(".yml")) { + return readPnpmVersions(content); + } + return readNpmVersions(content); +} + +// npm package-lock.json (lockfileVersion 3): the resolved version lives at +// packages["node_modules/"].version. +function readNpmVersions(content: string): Record { + const lock: unknown = JSON.parse(content); + const out: Record = {}; + if (!isRecord(lock) || !isRecord(lock.packages)) return out; + for (const pkg of VERIFIED_PACKAGES) { + const entry = lock.packages[`node_modules/${pkg}`]; + if (isRecord(entry) && typeof entry.version === "string") { + out[pkg] = entry.version; + } + } + return out; +} + +// pnpm-lock.yaml (v9): the root importer records the resolved version at +// importers["."].dependencies[""].version. That field carries a +// peer-dependency suffix (e.g. "0.76.1(react@19.2.4)"); strip it at the first +// "(" to recover the bare semver, which semver never contains. +function readPnpmVersions(content: string): Record { + const lock: unknown = parseYaml(content); + const out: Record = {}; + if (!isRecord(lock) || !isRecord(lock.importers)) return out; + const root = lock.importers["."]; + if (!isRecord(root)) return out; + const deps = { + ...(isRecord(root.dependencies) ? root.dependencies : {}), + ...(isRecord(root.devDependencies) ? root.devDependencies : {}), + }; + for (const pkg of VERIFIED_PACKAGES) { + const entry = deps[pkg]; + if (isRecord(entry) && typeof entry.version === "string") { + out[pkg] = entry.version.split("(")[0]; + } + } + return out; +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { version: { type: "string" } }, + }); + + const lockPaths = positionals.length + ? positionals.map((p) => resolve(ROOT, p)) + : [ + join(ROOT, "template/pnpm-lock.yaml"), + join(ROOT, "template/package-lock.json"), + ]; + + let expected: Record; + if (values.version) { + expected = Object.fromEntries( + VERIFIED_PACKAGES.map((pkg) => [pkg, values.version as string]), + ); + } else { + const templatePkg = JSON.parse( + readFileSync(join(ROOT, "template/package.json"), "utf-8"), + ); + const deps: Record = templatePkg.dependencies ?? {}; + expected = Object.fromEntries( + VERIFIED_PACKAGES.filter((pkg) => pkg in deps).map((pkg) => [ + pkg, + deps[pkg], + ]), + ); + } + + const { ok, mismatches } = verifyLockVersions(lockPaths, expected); + if (!ok) { + for (const m of mismatches) { + console.error( + `Version mismatch in ${relative(ROOT, m.lockfile) || m.lockfile}: ` + + `"${m.package}" resolves to ${m.found ?? ""} ` + + `(expected ${m.expected}).`, + ); + } + process.exit(1); + } + for (const path of lockPaths) { + console.log( + `✓ ${relative(ROOT, path) || path} resolves @databricks/* to the pinned version`, + ); + } +} diff --git a/tools/publish-template-tag.ts b/tools/publish-template-tag.ts index 6a695e5b0..5a637c008 100644 --- a/tools/publish-template-tag.ts +++ b/tools/publish-template-tag.ts @@ -11,6 +11,8 @@ import { spawnSync } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { verifyLockVersions } from "./check-template-lock-versions"; + const ROOT = process.cwd(); const version = process.argv[2]; if (!version) { @@ -101,6 +103,52 @@ if (pnpmExit !== 0) { } console.log("✓ template/pnpm-lock.yaml updated (pnpm install)"); +// 2d. Guard: both regenerated locks must resolve @databricks/* to the just-set +// version. A stale lock here is the Phase-1 regression — abort, don't commit. +const templateLocks = [ + join(ROOT, "template", "package-lock.json"), + join(ROOT, "template", "pnpm-lock.yaml"), +]; +const { ok, mismatches } = verifyLockVersions(templateLocks, { + "@databricks/appkit": version, + "@databricks/appkit-ui": version, +}); +if (!ok) { + for (const m of mismatches) { + console.error( + `Lock version mismatch in ${m.lockfile}: "${m.package}" resolves to ` + + `${m.found ?? ""} (expected ${m.expected}).`, + ); + } + console.error( + "Aborting release: regenerated locks disagree with the version.", + ); + process.exit(1); +} +console.log( + "✓ both template locks resolve @databricks/* to the published version", +); + +// 2e. Guard: both committed locks must resolve to the PUBLIC npm registry. +// Fail-closed (no --rewrite): a JFrog/internal URL on this path means the +// environment is wrong and the release must abort, not silently rewrite. +for (const lock of ["template/package-lock.json", "template/pnpm-lock.yaml"]) { + if ( + run("pnpm", [ + "exec", + "tsx", + "tools/check-template-lock-registry.ts", + lock, + "--allow-file", + ]) !== 0 + ) { + console.error( + `Aborting release: ${lock} references a non-public registry.`, + ); + process.exit(1); + } +} + // 3. Git add, commit, tag, push const commands: [string, string[]][] = [ [