|
| 1 | +import fs from 'node:fs/promises' |
| 2 | +import path from 'node:path' |
| 3 | + |
| 4 | +type BumpType = 'major' | 'minor' | 'patch' |
| 5 | + |
| 6 | +type ParsedChangeset = { |
| 7 | + file: string |
| 8 | + packages: Set<string> |
| 9 | +} |
| 10 | + |
| 11 | +const CHANGESET_DIR = path.join(process.cwd(), '.changeset') |
| 12 | +const NODE_PKG = '@transloadit/node' |
| 13 | +const MCP_SERVER_PKG = '@transloadit/mcp-server' |
| 14 | + |
| 15 | +async function listChangesetFiles(): Promise<string[]> { |
| 16 | + let entries: string[] |
| 17 | + try { |
| 18 | + entries = await fs.readdir(CHANGESET_DIR) |
| 19 | + } catch { |
| 20 | + return [] |
| 21 | + } |
| 22 | + |
| 23 | + return entries |
| 24 | + .filter((name) => name.endsWith('.md')) |
| 25 | + .filter((name) => name.toLowerCase() !== 'readme.md') |
| 26 | + .map((name) => path.join(CHANGESET_DIR, name)) |
| 27 | +} |
| 28 | + |
| 29 | +function parseFrontmatterPackages(markdown: string): Set<string> { |
| 30 | + // Changesets frontmatter is YAML-ish and looks like: |
| 31 | + // --- |
| 32 | + // "@transloadit/node": patch |
| 33 | + // "@transloadit/mcp-server": patch |
| 34 | + // --- |
| 35 | + const first = markdown.indexOf('---') |
| 36 | + if (first === -1) return new Set() |
| 37 | + const second = markdown.indexOf('---', first + 3) |
| 38 | + if (second === -1) return new Set() |
| 39 | + |
| 40 | + const frontmatter = markdown.slice(first + 3, second) |
| 41 | + const pkgs = new Set<string>() |
| 42 | + const re = /["']([^"']+)["']\s*:\s*(major|minor|patch)\b/g |
| 43 | + |
| 44 | + for (const match of frontmatter.matchAll(re)) { |
| 45 | + const pkg = match[1] |
| 46 | + const bump = match[2] as BumpType |
| 47 | + if (pkg && bump) pkgs.add(pkg) |
| 48 | + } |
| 49 | + return pkgs |
| 50 | +} |
| 51 | + |
| 52 | +async function parseChangesets(files: string[]): Promise<ParsedChangeset[]> { |
| 53 | + const out: ParsedChangeset[] = [] |
| 54 | + for (const file of files) { |
| 55 | + const markdown = await fs.readFile(file, 'utf8') |
| 56 | + out.push({ file, packages: parseFrontmatterPackages(markdown) }) |
| 57 | + } |
| 58 | + return out |
| 59 | +} |
| 60 | + |
| 61 | +function fail(message: string): never { |
| 62 | + // stderr only, so it plays well with JSON-only tools. |
| 63 | + process.stderr.write(`${message}\n`) |
| 64 | + process.exit(1) |
| 65 | +} |
| 66 | + |
| 67 | +async function main(): Promise<void> { |
| 68 | + const files = await listChangesetFiles() |
| 69 | + if (files.length === 0) return |
| 70 | + |
| 71 | + const changesets = await parseChangesets(files) |
| 72 | + const touched = new Set<string>() |
| 73 | + for (const cs of changesets) { |
| 74 | + for (const pkg of cs.packages) touched.add(pkg) |
| 75 | + } |
| 76 | + |
| 77 | + // One-way coupling policy: |
| 78 | + // If @transloadit/node is being released, also release @transloadit/mcp-server |
| 79 | + // so the published mcp-server versions stay "in sync" with node evolution. |
| 80 | + const touchesNode = touched.has(NODE_PKG) |
| 81 | + const touchesMcpServer = touched.has(MCP_SERVER_PKG) |
| 82 | + |
| 83 | + if (touchesNode && !touchesMcpServer) { |
| 84 | + fail( |
| 85 | + [ |
| 86 | + `Changeset policy violation: ${NODE_PKG} is being released, but ${MCP_SERVER_PKG} is not.`, |
| 87 | + '', |
| 88 | + `Add a patch changeset for ${MCP_SERVER_PKG} (even if no code changed) so the published MCP server`, |
| 89 | + 'can be tracked as validated against the latest @transloadit/node.', |
| 90 | + '', |
| 91 | + 'Example:', |
| 92 | + ' corepack yarn changeset', |
| 93 | + ` (select ${MCP_SERVER_PKG} -> patch)`, |
| 94 | + ` Summary: "chore: release mcp-server alongside @transloadit/node"`, |
| 95 | + ].join('\n'), |
| 96 | + ) |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +await main() |
0 commit comments