feat: add package-first release flow

This commit is contained in:
2026-03-20 15:10:46 +08:00
parent 3600f3fcd9
commit e13a60369d
16 changed files with 764 additions and 191 deletions
+320
View File
@@ -0,0 +1,320 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const packagesRoot = path.join(repoRoot, "packages");
const tempPrefix = path.join(os.tmpdir(), "cadence-ui-package-consumer-");
const keepArtifacts = process.env.CADENCE_KEEP_PACKAGE_SMOKE === "1";
const skipBuild = process.env.CADENCE_SKIP_PACKAGE_BUILD === "1";
const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
function toPosixPath(value) {
return value.split(path.sep).join(path.posix.sep);
}
async function readJson(filePath) {
return JSON.parse(await fs.readFile(filePath, "utf8"));
}
function formatCommand(command, args) {
return [command, ...args].join(" ");
}
async function run(command, args, options = {}) {
await new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: options.cwd ?? repoRoot,
env: {
...process.env,
...(options.env ?? {})
},
stdio: "inherit"
});
child.once("error", reject);
child.once("exit", (code, signal) => {
if (code === 0) {
resolve();
return;
}
reject(
new Error(
`${formatCommand(command, args)} failed with code ${code ?? "null"} and signal ${
signal ?? "null"
}.`
)
);
});
});
}
async function packPackage(packageDir, outputDir) {
const before = new Set(await fs.readdir(outputDir));
await run(
pnpmCommand,
["pack", "--pack-destination", outputDir],
{
cwd: packageDir,
env: {
CI: "1"
}
}
);
const after = await fs.readdir(outputDir);
const created = after.filter((entry) => !before.has(entry) && entry.endsWith(".tgz"));
if (created.length !== 1) {
throw new Error(
`Expected exactly one tarball from ${packageDir}, received ${created.length}.`
);
}
return path.join(outputDir, created[0]);
}
function toFileDependency(projectDir, absolutePath) {
let relativePath = toPosixPath(path.relative(projectDir, absolutePath));
if (!relativePath.startsWith(".")) {
relativePath = `./${relativePath}`;
}
return `file:${relativePath}`;
}
async function writeConsumerFixture({
projectDir,
tokensTarball,
uiTarball,
rootPackage,
uiPackage
}) {
const packageJson = {
name: "cadence-ui-package-consumer-smoke",
private: true,
type: "module",
dependencies: {
"@ai-ui/tokens": toFileDependency(projectDir, tokensTarball),
"@ai-ui/ui": toFileDependency(projectDir, uiTarball),
react: uiPackage.devDependencies.react,
"react-dom": uiPackage.devDependencies["react-dom"]
},
devDependencies: {
"@tailwindcss/vite": rootPackage.devDependencies["@tailwindcss/vite"],
"@types/react": rootPackage.devDependencies["@types/react"],
"@types/react-dom": rootPackage.devDependencies["@types/react-dom"],
tailwindcss: rootPackage.devDependencies.tailwindcss,
typescript: rootPackage.devDependencies.typescript,
vite: rootPackage.devDependencies.vite
},
pnpm: {
overrides: {
"@ai-ui/tokens": toFileDependency(projectDir, tokensTarball)
}
}
};
const tsconfig = {
compilerOptions: {
target: "ES2022",
module: "ESNext",
moduleResolution: "Bundler",
jsx: "react-jsx",
strict: true,
esModuleInterop: true,
skipLibCheck: true,
noEmit: true,
types: ["react", "react-dom"]
},
include: ["src", "vite.config.ts"]
};
const viteConfig = `import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [tailwindcss()]
});
`;
const indexHtml = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cadence UI Package Smoke</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
`;
const stylesSource = `@import "tailwindcss";
@import "@ai-ui/ui/styles.css";
@source "../node_modules/@ai-ui/ui/src";
`;
const mainSource = `import { createRoot } from "react-dom/client";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
Input
} from "@ai-ui/ui";
import { setTheme } from "@ai-ui/tokens";
import "./styles.css";
setTheme("light");
function App() {
return (
<main className="min-h-screen bg-[var(--color-background)] p-8 text-[var(--color-foreground)]">
<div className="mx-auto grid max-w-xl gap-6">
<div className="grid gap-3">
<h1 className="text-2xl font-semibold">Cadence UI package smoke</h1>
<Input defaultValue="team@cadence.dev" placeholder="Email" />
</div>
<Dialog>
<DialogTrigger asChild>
<Button>Open package smoke dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Package consumer</DialogTitle>
<DialogDescription>
Verifies published package exports typecheck and build in a consumer app.
</DialogDescription>
</DialogContent>
</Dialog>
</div>
</main>
);
}
const root = document.getElementById("root");
if (!root) {
throw new Error("Missing root element.");
}
createRoot(root).render(<App />);
`;
await fs.mkdir(path.join(projectDir, "src"), { recursive: true });
await fs.writeFile(path.join(projectDir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`);
await fs.writeFile(path.join(projectDir, "tsconfig.json"), `${JSON.stringify(tsconfig, null, 2)}\n`);
await fs.writeFile(path.join(projectDir, "vite.config.ts"), viteConfig);
await fs.writeFile(path.join(projectDir, "index.html"), indexHtml);
await fs.writeFile(path.join(projectDir, "src", "global.d.ts"), 'declare module "*.css";\n');
await fs.writeFile(path.join(projectDir, "src", "styles.css"), stylesSource);
await fs.writeFile(path.join(projectDir, "src", "main.tsx"), mainSource);
}
async function main() {
const [rootPackage, uiPackage, tokensPackage] = await Promise.all([
readJson(path.join(repoRoot, "package.json")),
readJson(path.join(packagesRoot, "ui", "package.json")),
readJson(path.join(packagesRoot, "tokens", "package.json"))
]);
if (uiPackage.version !== tokensPackage.version) {
throw new Error(
`Expected @ai-ui/ui and @ai-ui/tokens to share a version. Received ${uiPackage.version} and ${tokensPackage.version}.`
);
}
const projectDir = await fs.mkdtemp(tempPrefix);
const packDir = path.join(projectDir, "packed");
let succeeded = false;
const cleanup = async () => {
if (!succeeded || keepArtifacts) {
console.log(`Keeping package smoke fixture at ${projectDir}`);
return;
}
await fs.rm(projectDir, { force: true, recursive: true });
};
try {
if (!skipBuild) {
console.log("Building workspace packages for package smoke");
await run(pnpmCommand, ["build"], {
cwd: repoRoot,
env: {
CI: "1"
}
});
}
await fs.mkdir(packDir, { recursive: true });
console.log("Packing @ai-ui/tokens");
const tokensTarball = await packPackage(path.join(packagesRoot, "tokens"), packDir);
console.log("Packing @ai-ui/ui");
const uiTarball = await packPackage(path.join(packagesRoot, "ui"), packDir);
console.log(`Creating package consumer fixture in ${projectDir}`);
await writeConsumerFixture({
projectDir,
tokensTarball,
uiTarball,
rootPackage,
uiPackage
});
console.log("Installing package consumer dependencies");
await run(pnpmCommand, ["install", "--ignore-workspace"], {
cwd: projectDir,
env: {
CI: "1"
}
});
console.log("Verifying CommonJS package entrypoints");
await run(process.execPath, ["-e", 'const ui=require("@ai-ui/ui"); const tokens=require("@ai-ui/tokens"); if(!ui.Button||!tokens.setTheme){throw new Error("Missing CommonJS export.");}'], {
cwd: projectDir
});
console.log("Verifying ESM package entrypoints");
await run(process.execPath, ["--input-type=module", "-e", 'const ui=await import("@ai-ui/ui"); const tokens=await import("@ai-ui/tokens"); if(!ui.Button||!tokens.setTheme){throw new Error("Missing ESM export.");}'], {
cwd: projectDir
});
console.log("Typechecking the package consumer fixture");
await run(pnpmCommand, ["exec", "tsc", "-p", "tsconfig.json", "--noEmit"], {
cwd: projectDir
});
console.log("Building the package consumer fixture");
await run(pnpmCommand, ["exec", "vite", "build"], {
cwd: projectDir,
env: {
CI: "1"
}
});
succeeded = true;
console.log("Package consumer smoke test passed.");
} catch (error) {
console.error(`Package consumer smoke test failed. Fixture preserved at ${projectDir}`);
throw error;
} finally {
await cleanup();
}
}
await main();