Skip to content

Commit c25f046

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

File tree

4 files changed

+478
-0
lines changed

4 files changed

+478
-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: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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([{ packageName: 'hive', bumpType: 'major' }]);
80+
});
81+
82+
it('parses multiple packages', () => {
83+
const frontmatter = `'@graphql-hive/cli': patch
84+
'@graphql-hive/core': minor
85+
hive: major`;
86+
expect(parsePackageEntries(frontmatter)).toEqual([
87+
{ packageName: '@graphql-hive/cli', bumpType: 'patch' },
88+
{ packageName: '@graphql-hive/core', bumpType: 'minor' },
89+
{ packageName: 'hive', bumpType: 'major' },
90+
]);
91+
});
92+
93+
it('returns error for invalid lines', () => {
94+
const frontmatter = 'invalid line without colon';
95+
expect(parsePackageEntries(frontmatter)).toEqual([
96+
{ error: 'parse_error', line: 'invalid line without colon' },
97+
]);
98+
});
99+
100+
it('returns error for invalid bump type', () => {
101+
const frontmatter = "'@graphql-hive/cli': invalid";
102+
expect(parsePackageEntries(frontmatter)).toEqual([
103+
{ error: 'parse_error', line: "'@graphql-hive/cli': invalid" },
104+
]);
105+
});
106+
});
107+
108+
describe('isPackageIgnored', () => {
109+
const ignorePatterns = ['@hive/*', 'integration-tests', 'eslint-plugin-hive'];
110+
111+
it('matches exact package names', () => {
112+
expect(isPackageIgnored('integration-tests', ignorePatterns)).toBe(true);
113+
expect(isPackageIgnored('eslint-plugin-hive', ignorePatterns)).toBe(true);
114+
});
115+
116+
it('matches glob patterns', () => {
117+
expect(isPackageIgnored('@hive/api', ignorePatterns)).toBe(true);
118+
expect(isPackageIgnored('@hive/storage', ignorePatterns)).toBe(true);
119+
expect(isPackageIgnored('@hive/anything', ignorePatterns)).toBe(true);
120+
});
121+
122+
it('does not match non-ignored packages', () => {
123+
expect(isPackageIgnored('@graphql-hive/cli', ignorePatterns)).toBe(false);
124+
expect(isPackageIgnored('hive', ignorePatterns)).toBe(false);
125+
expect(isPackageIgnored('@graphql-hive/core', ignorePatterns)).toBe(false);
126+
});
127+
});
128+
129+
describe('validateChangeset', () => {
130+
const validPackages = new Set(['@graphql-hive/cli', '@graphql-hive/core', 'hive', '@hive/api']);
131+
const ignorePatterns = ['@hive/*', 'integration-tests'];
132+
133+
it('returns no errors for valid changeset', () => {
134+
const content = `---
135+
'@graphql-hive/cli': patch
136+
---
137+
138+
Fix something.`;
139+
const errors = validateChangeset('test.md', content, validPackages, ignorePatterns);
140+
expect(errors).toEqual([]);
141+
});
142+
143+
it('returns no errors for multiple valid packages', () => {
144+
const content = `---
145+
'@graphql-hive/cli': patch
146+
'@graphql-hive/core': minor
147+
---
148+
149+
Fix something.`;
150+
const errors = validateChangeset('test.md', content, validPackages, ignorePatterns);
151+
expect(errors).toEqual([]);
152+
});
153+
154+
it('returns error for non-existent package', () => {
155+
const content = `---
156+
'non-existent-package': patch
157+
---
158+
159+
Fix something.`;
160+
const errors = validateChangeset('test.md', content, validPackages, ignorePatterns);
161+
expect(errors).toHaveLength(1);
162+
expect(errors[0].file).toBe('test.md');
163+
expect(errors[0].message).toContain('does not exist in the monorepo');
164+
});
165+
166+
it('returns error for ignored package', () => {
167+
const content = `---
168+
'@hive/api': patch
169+
---
170+
171+
Fix something.`;
172+
const errors = validateChangeset('test.md', content, validPackages, ignorePatterns);
173+
expect(errors).toHaveLength(1);
174+
expect(errors[0].file).toBe('test.md');
175+
expect(errors[0].message).toContain('is in the changeset ignore list');
176+
});
177+
178+
it('returns error for invalid frontmatter', () => {
179+
const content = `No frontmatter here`;
180+
const errors = validateChangeset('test.md', content, validPackages, ignorePatterns);
181+
expect(errors).toHaveLength(1);
182+
expect(errors[0].message).toBe('Could not parse frontmatter');
183+
});
184+
185+
it('returns error for whitespace-only frontmatter', () => {
186+
const content = `---
187+
188+
---
189+
190+
Fix something.`;
191+
const errors = validateChangeset('test.md', content, validPackages, ignorePatterns);
192+
expect(errors).toHaveLength(1);
193+
expect(errors[0].message).toBe('Changeset has no packages listed');
194+
});
195+
196+
it('returns error for unparseable line', () => {
197+
const content = `---
198+
invalid line
199+
---
200+
201+
Fix something.`;
202+
const errors = validateChangeset('test.md', content, validPackages, ignorePatterns);
203+
expect(errors).toHaveLength(1);
204+
expect(errors[0].message).toContain('Could not parse line');
205+
});
206+
207+
it('returns multiple errors when applicable', () => {
208+
const content = `---
209+
'non-existent': patch
210+
'@hive/api': minor
211+
---
212+
213+
Fix something.`;
214+
const errors = validateChangeset('test.md', content, validPackages, ignorePatterns);
215+
expect(errors).toHaveLength(2);
216+
expect(errors[0].message).toContain('does not exist');
217+
expect(errors[1].message).toContain('ignore list');
218+
});
219+
});
220+
});

0 commit comments

Comments
 (0)