Skip to content

Commit 00043a4

Browse files
committed
feat(ci): add changeset package validation
1 parent aa2d445 commit 00043a4

File tree

4 files changed

+480
-0
lines changed

4 files changed

+480
-0
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
on:
2+
workflow_call:
3+
4+
jobs:
5+
validate-changesets:
6+
runs-on: ubuntu-22.04
7+
8+
steps:
9+
- name: checkout
10+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
11+
12+
- name: setup environment
13+
uses: ./.github/actions/setup
14+
with:
15+
actor: changeset-validation
16+
17+
- name: validate changeset packages
18+
run: pnpm tsx scripts/validate-changesets.ts

.github/workflows/pr.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ jobs:
6161
with:
6262
imageTag: ${{ github.event.pull_request.head.sha }}
6363

64+
# Changeset Validation
65+
# Validates that changesets reference valid packages that exist in the monorepo
66+
changeset-validation:
67+
uses: ./.github/workflows/changeset-validation.yaml
68+
6469
# ESLint and Prettier
6570
code-style:
6671
uses: ./.github/workflows/lint.yaml
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
isPackageIgnored,
4+
parseChangesetFrontmatter,
5+
parsePackageEntries,
6+
validateChangeset,
7+
} from './validate-changesets';
8+
9+
describe('validate-changesets', () => {
10+
describe('parseChangesetFrontmatter', () => {
11+
it('parses valid frontmatter', () => {
12+
const content = `---
13+
'@graphql-hive/cli': patch
14+
---
15+
16+
Some description here.`;
17+
expect(parseChangesetFrontmatter(content)).toBe("'@graphql-hive/cli': patch");
18+
});
19+
20+
it('parses multi-package frontmatter', () => {
21+
const content = `---
22+
'@graphql-hive/cli': patch
23+
'@graphql-hive/core': minor
24+
---
25+
26+
Some description here.`;
27+
expect(parseChangesetFrontmatter(content)).toBe(
28+
"'@graphql-hive/cli': patch\n'@graphql-hive/core': minor",
29+
);
30+
});
31+
32+
it('returns null for invalid frontmatter', () => {
33+
const content = `No frontmatter here`;
34+
expect(parseChangesetFrontmatter(content)).toBeNull();
35+
});
36+
37+
it('returns null for unclosed frontmatter', () => {
38+
const content = `---
39+
'@graphql-hive/cli': patch
40+
Some description here.`;
41+
expect(parseChangesetFrontmatter(content)).toBeNull();
42+
});
43+
44+
it('returns empty string for whitespace-only frontmatter', () => {
45+
const content = `---
46+
47+
---
48+
49+
Some description here.`;
50+
expect(parseChangesetFrontmatter(content)).toBe('');
51+
});
52+
53+
it('returns null for empty frontmatter (no newline between markers)', () => {
54+
const content = `---
55+
---
56+
57+
Some description here.`;
58+
expect(parseChangesetFrontmatter(content)).toBeNull();
59+
});
60+
});
61+
62+
describe('parsePackageEntries', () => {
63+
it('parses single-quoted package names', () => {
64+
const frontmatter = "'@graphql-hive/cli': patch";
65+
expect(parsePackageEntries(frontmatter)).toEqual([
66+
{ packageName: '@graphql-hive/cli', bumpType: 'patch' },
67+
]);
68+
});
69+
70+
it('parses double-quoted package names', () => {
71+
const frontmatter = '"@graphql-hive/cli": minor';
72+
expect(parsePackageEntries(frontmatter)).toEqual([
73+
{ packageName: '@graphql-hive/cli', bumpType: 'minor' },
74+
]);
75+
});
76+
77+
it('parses unquoted package names', () => {
78+
const frontmatter = 'hive: major';
79+
expect(parsePackageEntries(frontmatter)).toEqual([
80+
{ packageName: 'hive', bumpType: 'major' },
81+
]);
82+
});
83+
84+
it('parses multiple packages', () => {
85+
const frontmatter = `'@graphql-hive/cli': patch
86+
'@graphql-hive/core': minor
87+
hive: major`;
88+
expect(parsePackageEntries(frontmatter)).toEqual([
89+
{ packageName: '@graphql-hive/cli', bumpType: 'patch' },
90+
{ packageName: '@graphql-hive/core', bumpType: 'minor' },
91+
{ packageName: 'hive', bumpType: 'major' },
92+
]);
93+
});
94+
95+
it('returns error for invalid lines', () => {
96+
const frontmatter = 'invalid line without colon';
97+
expect(parsePackageEntries(frontmatter)).toEqual([
98+
{ error: 'parse_error', line: 'invalid line without colon' },
99+
]);
100+
});
101+
102+
it('returns error for invalid bump type', () => {
103+
const frontmatter = "'@graphql-hive/cli': invalid";
104+
expect(parsePackageEntries(frontmatter)).toEqual([
105+
{ error: 'parse_error', line: "'@graphql-hive/cli': invalid" },
106+
]);
107+
});
108+
});
109+
110+
describe('isPackageIgnored', () => {
111+
const ignorePatterns = ['@hive/*', 'integration-tests', 'eslint-plugin-hive'];
112+
113+
it('matches exact package names', () => {
114+
expect(isPackageIgnored('integration-tests', ignorePatterns)).toBe(true);
115+
expect(isPackageIgnored('eslint-plugin-hive', ignorePatterns)).toBe(true);
116+
});
117+
118+
it('matches glob patterns', () => {
119+
expect(isPackageIgnored('@hive/api', ignorePatterns)).toBe(true);
120+
expect(isPackageIgnored('@hive/storage', ignorePatterns)).toBe(true);
121+
expect(isPackageIgnored('@hive/anything', ignorePatterns)).toBe(true);
122+
});
123+
124+
it('does not match non-ignored packages', () => {
125+
expect(isPackageIgnored('@graphql-hive/cli', ignorePatterns)).toBe(false);
126+
expect(isPackageIgnored('hive', ignorePatterns)).toBe(false);
127+
expect(isPackageIgnored('@graphql-hive/core', ignorePatterns)).toBe(false);
128+
});
129+
});
130+
131+
describe('validateChangeset', () => {
132+
const validPackages = new Set(['@graphql-hive/cli', '@graphql-hive/core', 'hive', '@hive/api']);
133+
const ignorePatterns = ['@hive/*', 'integration-tests'];
134+
135+
it('returns no errors for valid changeset', () => {
136+
const content = `---
137+
'@graphql-hive/cli': patch
138+
---
139+
140+
Fix something.`;
141+
const errors = validateChangeset('test.md', content, validPackages, ignorePatterns);
142+
expect(errors).toEqual([]);
143+
});
144+
145+
it('returns no errors for multiple valid packages', () => {
146+
const content = `---
147+
'@graphql-hive/cli': patch
148+
'@graphql-hive/core': minor
149+
---
150+
151+
Fix something.`;
152+
const errors = validateChangeset('test.md', content, validPackages, ignorePatterns);
153+
expect(errors).toEqual([]);
154+
});
155+
156+
it('returns error for non-existent package', () => {
157+
const content = `---
158+
'non-existent-package': patch
159+
---
160+
161+
Fix something.`;
162+
const errors = validateChangeset('test.md', content, validPackages, ignorePatterns);
163+
expect(errors).toHaveLength(1);
164+
expect(errors[0].file).toBe('test.md');
165+
expect(errors[0].message).toContain('does not exist in the monorepo');
166+
});
167+
168+
it('returns error for ignored package', () => {
169+
const content = `---
170+
'@hive/api': patch
171+
---
172+
173+
Fix something.`;
174+
const errors = validateChangeset('test.md', content, validPackages, ignorePatterns);
175+
expect(errors).toHaveLength(1);
176+
expect(errors[0].file).toBe('test.md');
177+
expect(errors[0].message).toContain('is in the changeset ignore list');
178+
});
179+
180+
it('returns error for invalid frontmatter', () => {
181+
const content = `No frontmatter here`;
182+
const errors = validateChangeset('test.md', content, validPackages, ignorePatterns);
183+
expect(errors).toHaveLength(1);
184+
expect(errors[0].message).toBe('Could not parse frontmatter');
185+
});
186+
187+
it('returns error for whitespace-only frontmatter', () => {
188+
const content = `---
189+
190+
---
191+
192+
Fix something.`;
193+
const errors = validateChangeset('test.md', content, validPackages, ignorePatterns);
194+
expect(errors).toHaveLength(1);
195+
expect(errors[0].message).toBe('Changeset has no packages listed');
196+
});
197+
198+
it('returns error for unparseable line', () => {
199+
const content = `---
200+
invalid line
201+
---
202+
203+
Fix something.`;
204+
const errors = validateChangeset('test.md', content, validPackages, ignorePatterns);
205+
expect(errors).toHaveLength(1);
206+
expect(errors[0].message).toContain('Could not parse line');
207+
});
208+
209+
it('returns multiple errors when applicable', () => {
210+
const content = `---
211+
'non-existent': patch
212+
'@hive/api': minor
213+
---
214+
215+
Fix something.`;
216+
const errors = validateChangeset('test.md', content, validPackages, ignorePatterns);
217+
expect(errors).toHaveLength(2);
218+
expect(errors[0].message).toContain('does not exist');
219+
expect(errors[1].message).toContain('ignore list');
220+
});
221+
});
222+
});

0 commit comments

Comments
 (0)