Skip to content

Commit e3596b5

Browse files
authored
chore: enforce node -> mcp-server changeset coupling (#332)
* chore: guard node -> mcp-server releases * Apply suggestion from @kvz
1 parent cbc6192 commit e3596b5

3 files changed

Lines changed: 109 additions & 5 deletions

File tree

.ai/skills/release/SKILL.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ description: Checklist for releasing packages from this monorepo (code PR -> Ver
1717
2. Run `corepack yarn verify:full` locally once before pushing.
1818
- This is the fastest way to catch the common CI-only failure: transloadit parity drift in `Verify (full)`.
1919
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`.
20-
4. Commit + push branch
21-
5. Open PR, wait for CI green
22-
6. Squash-merge the PR
20+
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.
21+
- 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).
22+
- `yarn check`/`yarn verify` will fail fast if you forget.
23+
5. Commit + push branch
24+
6. Open PR, wait for CI green
25+
7. Squash-merge the PR
2326

2427
Notes:
2528
1. When creating PRs with `gh pr create` from a shell, avoid unescaped backticks in the `--body` string.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
"packages/*"
88
],
99
"scripts": {
10-
"check": "yarn fix:deps && yarn fix:js && yarn lint:ts && yarn test:unit",
11-
"verify": "yarn lint:publish && yarn lint:deps && yarn lint:js && yarn lint:ts && yarn test:unit",
10+
"check": "yarn lint:changesets && yarn fix:deps && yarn fix:js && yarn lint:ts && yarn test:unit",
11+
"verify": "yarn lint:changesets && yarn lint:publish && yarn lint:deps && yarn lint:js && yarn lint:ts && yarn test:unit",
1212
"verify:full": "yarn verify && yarn knip && yarn parity:transloadit && yarn test:types",
1313
"lint:js": "biome check .",
1414
"lint:ts": "yarn tsc:types && yarn tsc:node && yarn tsc:zod",
15+
"lint:changesets": "node scripts/guard-changesets.ts",
1516
"lint": "yarn lint:js",
1617
"fix": "yarn fix:js",
1718
"fix:js": "biome check --write .",

scripts/guard-changesets.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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

Comments
 (0)