Fix Docker workspace lockfile drift and add sync guard

This commit is contained in:
dirtydishes 2026-05-07 02:08:02 -04:00
parent e69bf295c8
commit dc0aeaa7d2
8 changed files with 295 additions and 4 deletions

View file

@ -0,0 +1,244 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
type DependencyMap = Record<string, string>;
type LockWorkspace = {
name?: string;
dependencies?: DependencyMap;
devDependencies?: DependencyMap;
optionalDependencies?: DependencyMap;
peerDependencies?: DependencyMap;
};
type BunLock = {
lockfileVersion?: number;
configVersion?: number;
workspaces?: Record<string, LockWorkspace>;
packages?: Record<string, unknown>;
};
type RootPackageManifest = {
workspaces?: string[];
};
const repoRoot = path.resolve(import.meta.dir, "..");
const deploymentRoot = path.join(repoRoot, "deployment/docker/workspace-root");
const rootPackagePath = path.join(repoRoot, "package.json");
const deploymentPackagePath = path.join(deploymentRoot, "package.json");
const rootTsconfigPath = path.join(repoRoot, "tsconfig.base.json");
const deploymentTsconfigPath = path.join(deploymentRoot, "tsconfig.base.json");
const rootLockPath = path.join(repoRoot, "bun.lock");
const deploymentLockPath = path.join(deploymentRoot, "bun.lock");
const readUtf8 = async (filePath: string): Promise<string> => {
return readFile(filePath, "utf8");
};
const parseObjectLiteral = async <T>(filePath: string): Promise<T> => {
const raw = await readUtf8(filePath);
try {
const parsed = Function(`"use strict"; return (${raw});`)() as T;
return parsed;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to parse ${filePath}: ${message}`);
}
};
const stableSortObject = (value: unknown): unknown => {
if (Array.isArray(value)) {
return value.map(stableSortObject);
}
if (value && typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, nested]) => [key, stableSortObject(nested)] as const);
return Object.fromEntries(entries);
}
return value;
};
const stableStringify = (value: unknown): string => {
return JSON.stringify(stableSortObject(value));
};
const listWorkspacePaths = async (workspacePatterns: string[]): Promise<string[]> => {
const paths = new Set<string>();
for (const pattern of workspacePatterns) {
const globPattern = pattern.endsWith("/") ? `${pattern}package.json` : `${pattern}/package.json`;
const glob = new Bun.Glob(globPattern);
for await (const match of glob.scan({ cwd: repoRoot })) {
const normalized = match.replaceAll("\\", "/");
paths.add(path.posix.dirname(normalized));
}
}
return Array.from(paths).sort((a, b) => a.localeCompare(b));
};
const normalizedDependencyMap = (input: DependencyMap | undefined): DependencyMap => {
if (!input) {
return {};
}
return Object.fromEntries(
Object.entries(input)
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, version]) => [name, version])
);
};
const formatDependencyDiff = (
workspacePath: string,
section: string,
expected: DependencyMap,
actual: DependencyMap
): string[] => {
const issues: string[] = [];
const expectedKeys = new Set(Object.keys(expected));
const actualKeys = new Set(Object.keys(actual));
for (const key of expectedKeys) {
if (!actualKeys.has(key)) {
issues.push(`${workspacePath} ${section}: missing ${key}@${expected[key]}`);
continue;
}
if (expected[key] !== actual[key]) {
issues.push(
`${workspacePath} ${section}: ${key} expected ${expected[key]} but found ${actual[key]}`
);
}
}
for (const key of actualKeys) {
if (!expectedKeys.has(key)) {
issues.push(`${workspacePath} ${section}: extra ${key}@${actual[key]}`);
}
}
return issues;
};
const check = async (): Promise<number> => {
const issues: string[] = [];
const [rootPackage, deploymentPackage, rootTsconfig, deploymentTsconfig, rootLock, deploymentLock] =
await Promise.all([
parseObjectLiteral<RootPackageManifest>(rootPackagePath),
parseObjectLiteral(deploymentPackagePath),
parseObjectLiteral(rootTsconfigPath),
parseObjectLiteral(deploymentTsconfigPath),
parseObjectLiteral<BunLock>(rootLockPath),
parseObjectLiteral<BunLock>(deploymentLockPath)
]);
const rootPackageSnapshot = stableStringify(rootPackage);
const deploymentPackageSnapshot = stableStringify(deploymentPackage);
if (rootPackageSnapshot !== deploymentPackageSnapshot) {
issues.push(
"deployment/docker/workspace-root/package.json does not match repo-root package.json"
);
}
const rootTsconfigSnapshot = stableStringify(rootTsconfig);
const deploymentTsconfigSnapshot = stableStringify(deploymentTsconfig);
if (rootTsconfigSnapshot !== deploymentTsconfigSnapshot) {
issues.push(
"deployment/docker/workspace-root/tsconfig.base.json does not match repo-root tsconfig.base.json"
);
}
const rootWorkspaces = rootLock.workspaces ?? {};
const deploymentWorkspaces = deploymentLock.workspaces ?? {};
const workspacePatterns = rootPackage.workspaces ?? [];
const workspacePackagePaths = await listWorkspacePaths(workspacePatterns);
for (const workspacePath of workspacePackagePaths) {
const packageJsonPath = path.join(repoRoot, workspacePath, "package.json");
const workspacePackage = (await parseObjectLiteral(packageJsonPath)) as LockWorkspace;
const deploymentWorkspace = deploymentWorkspaces[workspacePath];
if (!deploymentWorkspace) {
issues.push(`deployment lock is missing workspace entry: ${workspacePath}`);
continue;
}
const sections: Array<keyof LockWorkspace> = [
"dependencies",
"devDependencies",
"optionalDependencies",
"peerDependencies"
];
for (const section of sections) {
const expectedMap = normalizedDependencyMap(workspacePackage[section] as DependencyMap | undefined);
const actualMap = normalizedDependencyMap(
deploymentWorkspace[section] as DependencyMap | undefined
);
issues.push(...formatDependencyDiff(workspacePath, section, expectedMap, actualMap));
}
}
const workspacePaths = Array.from(
new Set([...Object.keys(rootWorkspaces), ...Object.keys(deploymentWorkspaces)])
).sort((a, b) => a.localeCompare(b));
for (const workspacePath of workspacePaths) {
const rootWorkspace = rootWorkspaces[workspacePath];
const deploymentWorkspace = deploymentWorkspaces[workspacePath];
if (!rootWorkspace) {
issues.push(`deployment lock has unexpected workspace entry: ${workspacePath}`);
continue;
}
if (!deploymentWorkspace) {
issues.push(`deployment lock is missing workspace entry: ${workspacePath}`);
continue;
}
if ((rootWorkspace.name ?? "") !== (deploymentWorkspace.name ?? "")) {
issues.push(
`${workspacePath} name mismatch: expected ${rootWorkspace.name ?? "(none)"} but found ${
deploymentWorkspace.name ?? "(none)"
}`
);
}
const sections: Array<keyof LockWorkspace> = [
"dependencies",
"devDependencies",
"optionalDependencies",
"peerDependencies"
];
for (const section of sections) {
const expectedMap = normalizedDependencyMap(rootWorkspace[section] as DependencyMap | undefined);
const actualMap = normalizedDependencyMap(
deploymentWorkspace[section] as DependencyMap | undefined
);
issues.push(...formatDependencyDiff(workspacePath, section, expectedMap, actualMap));
}
}
const rootPackagesSnapshot = stableStringify(rootLock.packages ?? {});
const deploymentPackagesSnapshot = stableStringify(deploymentLock.packages ?? {});
if (rootPackagesSnapshot !== deploymentPackagesSnapshot) {
issues.push(
"deployment/docker/workspace-root/bun.lock package resolutions differ from repo-root bun.lock"
);
}
if (issues.length > 0) {
console.error("Docker workspace snapshot is out of sync:");
for (const issue of issues) {
console.error(`- ${issue}`);
}
console.error("Run: bun run sync:docker-workspace");
return 1;
}
console.log("Docker workspace snapshot is in sync.");
return 0;
};
process.exitCode = await check();