Fix Docker workspace lockfile drift and add sync guard
This commit is contained in:
parent
e69bf295c8
commit
dc0aeaa7d2
8 changed files with 295 additions and 4 deletions
244
scripts/check-docker-workspace.ts
Normal file
244
scripts/check-docker-workspace.ts
Normal 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();
|
||||
19
scripts/sync-docker-workspace.ts
Normal file
19
scripts/sync-docker-workspace.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { copyFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dir, "..");
|
||||
const deploymentRoot = path.join(repoRoot, "deployment/docker/workspace-root");
|
||||
|
||||
const filesToSync = [
|
||||
"package.json",
|
||||
"bun.lock",
|
||||
"tsconfig.base.json"
|
||||
] as const;
|
||||
|
||||
for (const fileName of filesToSync) {
|
||||
const source = path.join(repoRoot, fileName);
|
||||
const destination = path.join(deploymentRoot, fileName);
|
||||
await copyFile(source, destination);
|
||||
console.log(`synced ${fileName}`);
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue