@@ -31,7 +31,7 @@ export const APPROVAL_LABEL = 'breaking-change-approved';
3131 * @returns The declared levels, lowercased, in file order.
3232 */
3333export function parseBumpLevels ( source ) {
34- const normalized = source . replaceAll ( / \r \n / gu , '\n' ) ;
34+ const normalized = source . replaceAll ( ' \r\n' , '\n' ) ;
3535 if ( ! normalized . startsWith ( '---\n' ) ) return [ ] ;
3636 const end = normalized . indexOf ( '\n---' , 3 ) ;
3737 if ( end === - 1 ) return [ ] ;
@@ -53,15 +53,24 @@ export function isChangesetFile(path) {
5353/**
5454 * Decide whether the gate passes.
5555 *
56- * @param input.addedFiles - Paths added by the pull request.
56+ * A changeset counts against the pull request when it declares `major` now and
57+ * did not already declare it on the base branch. Editing an existing changeset
58+ * up to `major` therefore counts, while merely touching one that was already
59+ * approved does not ask for the label a second time.
60+ *
61+ * @param input.changedFiles - Changeset paths the pull request adds or edits.
5762 * @param input.labels - Label names on the pull request.
58- * @param input.readFile - Reads one path; injected so this stays pure.
63+ * @param input.readFile - Reads one path at the pull request head.
64+ * @param input.readBaseFile - Reads one path on the base branch, or undefined
65+ * when the path does not exist there. Injected so this stays pure.
5966 * @returns The offending changesets and whether they are approved.
6067 */
6168export function evaluate ( input ) {
62- const majors = input . addedFiles
63- . filter ( isChangesetFile )
64- . filter ( ( path ) => parseBumpLevels ( input . readFile ( path ) ) . includes ( 'major' ) ) ;
69+ const majors = input . changedFiles . filter ( isChangesetFile ) . filter ( ( path ) => {
70+ if ( ! parseBumpLevels ( input . readFile ( path ) ) . includes ( 'major' ) ) return false ;
71+ const base = input . readBaseFile ( path ) ;
72+ return base === undefined || ! parseBumpLevels ( base ) . includes ( 'major' ) ;
73+ } ) ;
6574 const approved = input . labels . includes ( APPROVAL_LABEL ) ;
6675 return { majors, approved, ok : majors . length === 0 || approved } ;
6776}
@@ -87,22 +96,76 @@ function selfTest() {
8796 }
8897 }
8998
90- const files = { '.changeset/a.md' : '---\n"a": major\n---\n\nText.\n' } ;
99+ const MAJOR = '---\n"a": major\n---\n\nText.\n' ;
100+ const MINOR = '---\n"a": minor\n---\n\nText.\n' ;
101+ const files = { '.changeset/a.md' : MAJOR } ;
91102 const readFile = ( path ) => files [ path ] ;
92- const blocked = evaluate ( { addedFiles : [ '.changeset/a.md' ] , labels : [ ] , readFile } ) ;
93- if ( blocked . ok || blocked . majors . length !== 1 ) {
94- console . error ( 'self-test FAILED: an unlabelled major must be blocked' ) ;
95- failures += 1 ;
96- }
97- const allowed = evaluate ( { addedFiles : [ '.changeset/a.md' ] , labels : [ APPROVAL_LABEL ] , readFile } ) ;
98- if ( ! allowed . ok ) {
99- console . error ( 'self-test FAILED: a labelled major must pass' ) ;
100- failures += 1 ;
101- }
102- const readmeOnly = evaluate ( { addedFiles : [ '.changeset/README.md' ] , labels : [ ] , readFile : ( ) => '---\n"a": major\n---\n' } ) ;
103- if ( ! readmeOnly . ok ) {
104- console . error ( 'self-test FAILED: the changeset README is not a changeset' ) ;
105- failures += 1 ;
103+ const absentFromBase = ( ) => undefined ;
104+
105+ const gateCases = [
106+ {
107+ name : 'an unlabelled new major is blocked' ,
108+ input : { changedFiles : [ '.changeset/a.md' ] , labels : [ ] , readFile, readBaseFile : absentFromBase } ,
109+ ok : false ,
110+ majors : 1 ,
111+ } ,
112+ {
113+ name : 'a labelled new major passes' ,
114+ input : {
115+ changedFiles : [ '.changeset/a.md' ] ,
116+ labels : [ APPROVAL_LABEL ] ,
117+ readFile,
118+ readBaseFile : absentFromBase ,
119+ } ,
120+ ok : true ,
121+ majors : 1 ,
122+ } ,
123+ {
124+ name : 'the changeset README is not a changeset' ,
125+ input : {
126+ changedFiles : [ '.changeset/README.md' ] ,
127+ labels : [ ] ,
128+ readFile : ( ) => MAJOR ,
129+ readBaseFile : absentFromBase ,
130+ } ,
131+ ok : true ,
132+ majors : 0 ,
133+ } ,
134+ // The escape this gate exists to close: the file is not new, so a filter on
135+ // added paths alone would never see the bump rise from minor to major.
136+ {
137+ name : 'editing an existing changeset up to major is blocked' ,
138+ input : { changedFiles : [ '.changeset/a.md' ] , labels : [ ] , readFile, readBaseFile : ( ) => MINOR } ,
139+ ok : false ,
140+ majors : 1 ,
141+ } ,
142+ {
143+ name : 'touching an already-major changeset does not re-ask for the label' ,
144+ input : { changedFiles : [ '.changeset/a.md' ] , labels : [ ] , readFile, readBaseFile : ( ) => MAJOR } ,
145+ ok : true ,
146+ majors : 0 ,
147+ } ,
148+ {
149+ name : 'editing a changeset that stays below major passes' ,
150+ input : {
151+ changedFiles : [ '.changeset/a.md' ] ,
152+ labels : [ ] ,
153+ readFile : ( ) => MINOR ,
154+ readBaseFile : ( ) => MINOR ,
155+ } ,
156+ ok : true ,
157+ majors : 0 ,
158+ } ,
159+ ] ;
160+
161+ for ( const { name, input, ok, majors } of gateCases ) {
162+ const actual = evaluate ( input ) ;
163+ if ( actual . ok !== ok || actual . majors . length !== majors ) {
164+ console . error (
165+ `self-test FAILED: ${ name } — expected ok=${ ok } majors=${ majors } , got ok=${ actual . ok } majors=${ actual . majors . length } ` ,
166+ ) ;
167+ failures += 1 ;
168+ }
106169 }
107170
108171 const labelCases = [
@@ -122,7 +185,7 @@ function selfTest() {
122185 }
123186
124187 if ( failures > 0 ) process . exit ( 1 ) ;
125- console . log ( `check-major-changeset: self-test OK (${ cases . length + labelCases . length + 3 } cases)` ) ;
188+ console . log ( `check-major-changeset: self-test OK (${ cases . length + labelCases . length + gateCases . length } cases)` ) ;
126189}
127190
128191/**
@@ -143,15 +206,31 @@ export function parseLabels(raw) {
143206 return Array . isArray ( parsed ) ? parsed . filter ( ( name ) => typeof name === 'string' ) : [ ] ;
144207}
145208
146- function addedFilesAgainst ( baseSha ) {
209+ /**
210+ * Changeset paths the pull request adds, edits, or renames into place.
211+ *
212+ * `--diff-filter=d` keeps every status except deletion, so an edit that raises
213+ * an existing changeset to `major` is reported alongside a brand new one. A
214+ * deleted changeset cannot introduce a bump, so it is the only status dropped.
215+ */
216+ function changedFilesAgainst ( baseSha ) {
147217 const output = execFileSync (
148218 'git' ,
149- [ 'diff' , '--name-only' , '--diff-filter=A ' , `${ baseSha } ...HEAD` , '--' , '.changeset' ] ,
219+ [ 'diff' , '--name-only' , '--diff-filter=d ' , `${ baseSha } ...HEAD` , '--' , '.changeset' ] ,
150220 { encoding : 'utf8' } ,
151221 ) ;
152222 return output . split ( '\n' ) . filter ( ( line ) => line . length > 0 ) ;
153223}
154224
225+ /** The same path on the base branch, or undefined when it is new there. */
226+ function readBaseFile ( baseSha , path ) {
227+ try {
228+ return execFileSync ( 'git' , [ 'show' , `${ baseSha } :${ path } ` ] , { encoding : 'utf8' } ) ;
229+ } catch {
230+ return undefined ;
231+ }
232+ }
233+
155234function main ( ) {
156235 if ( process . argv . includes ( '--self-test' ) ) {
157236 selfTest ( ) ;
@@ -165,9 +244,10 @@ function main() {
165244 }
166245
167246 const result = evaluate ( {
168- addedFiles : addedFilesAgainst ( baseSha ) ,
247+ changedFiles : changedFilesAgainst ( baseSha ) ,
169248 labels : parseLabels ( process . env [ 'PR_LABELS_JSON' ] ) ,
170249 readFile : ( path ) => readFileSync ( path , 'utf8' ) ,
250+ readBaseFile : ( path ) => readBaseFile ( baseSha , path ) ,
171251 } ) ;
172252
173253 if ( result . ok ) {
0 commit comments