diff --git a/.ai/skills/release/SKILL.md b/.ai/skills/release/SKILL.md index 7dafda45..4cf5c28d 100644 --- a/.ai/skills/release/SKILL.md +++ b/.ai/skills/release/SKILL.md @@ -17,9 +17,12 @@ description: Checklist for releasing packages from this monorepo (code PR -> Ver 2. Run `corepack yarn verify:full` locally once before pushing. - This is the fastest way to catch the common CI-only failure: transloadit parity drift in `Verify (full)`. 3. If `verify:full` (or CI `Verify (full)`) fails with transloadit parity drift, apply the “Parity drift playbook” below, then re-run `corepack yarn verify:full`. - 4. Commit + push branch - 5. Open PR, wait for CI green - 6. Squash-merge the PR + 4. If you add a changeset for `@transloadit/node`, also add a similar changeset for `@transloadit/mcp-server` if it could affect its workings. The chances are, they are, since the latter is mostly a thin wrapper around the former. + - This repo enforces a one-way coupling: node releases should also publish a new mcp-server version (but mcp-server releases do not require node releases). + - `yarn check`/`yarn verify` will fail fast if you forget. + 5. Commit + push branch + 6. Open PR, wait for CI green + 7. Squash-merge the PR Notes: 1. When creating PRs with `gh pr create` from a shell, avoid unescaped backticks in the `--body` string. diff --git a/package.json b/package.json index 8d4c11ff..facd1407 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,12 @@ "packages/*" ], "scripts": { - "check": "yarn fix:deps && yarn fix:js && yarn lint:ts && yarn test:unit", - "verify": "yarn lint:publish && yarn lint:deps && yarn lint:js && yarn lint:ts && yarn test:unit", + "check": "yarn lint:changesets && yarn fix:deps && yarn fix:js && yarn lint:ts && yarn test:unit", + "verify": "yarn lint:changesets && yarn lint:publish && yarn lint:deps && yarn lint:js && yarn lint:ts && yarn test:unit", "verify:full": "yarn verify && yarn knip && yarn parity:transloadit && yarn test:types", "lint:js": "biome check .", "lint:ts": "yarn tsc:types && yarn tsc:node && yarn tsc:zod", + "lint:changesets": "node scripts/guard-changesets.ts", "lint": "yarn lint:js", "fix": "yarn fix:js", "fix:js": "biome check --write .", diff --git a/scripts/guard-changesets.ts b/scripts/guard-changesets.ts new file mode 100644 index 00000000..4c26277d --- /dev/null +++ b/scripts/guard-changesets.ts @@ -0,0 +1,100 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +type BumpType = 'major' | 'minor' | 'patch' + +type ParsedChangeset = { + file: string + packages: Set +} + +const CHANGESET_DIR = path.join(process.cwd(), '.changeset') +const NODE_PKG = '@transloadit/node' +const MCP_SERVER_PKG = '@transloadit/mcp-server' + +async function listChangesetFiles(): Promise { + let entries: string[] + try { + entries = await fs.readdir(CHANGESET_DIR) + } catch { + return [] + } + + return entries + .filter((name) => name.endsWith('.md')) + .filter((name) => name.toLowerCase() !== 'readme.md') + .map((name) => path.join(CHANGESET_DIR, name)) +} + +function parseFrontmatterPackages(markdown: string): Set { + // Changesets frontmatter is YAML-ish and looks like: + // --- + // "@transloadit/node": patch + // "@transloadit/mcp-server": patch + // --- + const first = markdown.indexOf('---') + if (first === -1) return new Set() + const second = markdown.indexOf('---', first + 3) + if (second === -1) return new Set() + + const frontmatter = markdown.slice(first + 3, second) + const pkgs = new Set() + const re = /["']([^"']+)["']\s*:\s*(major|minor|patch)\b/g + + for (const match of frontmatter.matchAll(re)) { + const pkg = match[1] + const bump = match[2] as BumpType + if (pkg && bump) pkgs.add(pkg) + } + return pkgs +} + +async function parseChangesets(files: string[]): Promise { + const out: ParsedChangeset[] = [] + for (const file of files) { + const markdown = await fs.readFile(file, 'utf8') + out.push({ file, packages: parseFrontmatterPackages(markdown) }) + } + return out +} + +function fail(message: string): never { + // stderr only, so it plays well with JSON-only tools. + process.stderr.write(`${message}\n`) + process.exit(1) +} + +async function main(): Promise { + const files = await listChangesetFiles() + if (files.length === 0) return + + const changesets = await parseChangesets(files) + const touched = new Set() + for (const cs of changesets) { + for (const pkg of cs.packages) touched.add(pkg) + } + + // One-way coupling policy: + // If @transloadit/node is being released, also release @transloadit/mcp-server + // so the published mcp-server versions stay "in sync" with node evolution. + const touchesNode = touched.has(NODE_PKG) + const touchesMcpServer = touched.has(MCP_SERVER_PKG) + + if (touchesNode && !touchesMcpServer) { + fail( + [ + `Changeset policy violation: ${NODE_PKG} is being released, but ${MCP_SERVER_PKG} is not.`, + '', + `Add a patch changeset for ${MCP_SERVER_PKG} (even if no code changed) so the published MCP server`, + 'can be tracked as validated against the latest @transloadit/node.', + '', + 'Example:', + ' corepack yarn changeset', + ` (select ${MCP_SERVER_PKG} -> patch)`, + ` Summary: "chore: release mcp-server alongside @transloadit/node"`, + ].join('\n'), + ) + } +} + +await main()