Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions tools/check-template-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<string, string> = {};
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 ?? "<missing>"} 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);
Expand Down
116 changes: 116 additions & 0 deletions tools/check-template-lock-versions.test.ts
Original file line number Diff line number Diff line change
@@ -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, string>): 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<string, unknown> = { "": { 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<string, unknown> = {};
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",
},
]);
});
171 changes: 171 additions & 0 deletions tools/check-template-lock-versions.ts
Original file line number Diff line number Diff line change
@@ -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 <v>]
*
* 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<string, string>,
): 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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

/** Resolved versions keyed by package name, dispatched by lockfile format. */
function readResolvedVersions(lockPath: string): Record<string, string> {
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/<pkg>"].version.
function readNpmVersions(content: string): Record<string, string> {
const lock: unknown = JSON.parse(content);
const out: Record<string, string> = {};
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["<pkg>"].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<string, string> {
const lock: unknown = parseYaml(content);
const out: Record<string, string> = {};
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<string, string>;
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<string, string> = 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 ?? "<missing>"} ` +
`(expected ${m.expected}).`,
);
}
process.exit(1);
}
for (const path of lockPaths) {
console.log(
`✓ ${relative(ROOT, path) || path} resolves @databricks/* to the pinned version`,
);
}
}
48 changes: 48 additions & 0 deletions tools/publish-template-tag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 ?? "<missing>"} (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[]][] = [
[
Expand Down
Loading