99const now = Date . parse ( "2026-09-05T12:00:00Z" ) ;
1010const hour = 60 * 60 * 1000 ;
1111const nightly = ( hoursAgo , overrides = { } ) => ( {
12- tag_name : "v1.0.1-nightly.20260905.123" ,
13- draft : false ,
14- published_at : new Date ( now - hoursAgo * hour ) . toISOString ( ) ,
12+ tagName : "v1.0.1-nightly.20260905.123" ,
13+ isDraft : false ,
14+ publishedAt : new Date ( now - hoursAgo * hour ) . toISOString ( ) ,
1515 ...overrides ,
1616} ) ;
1717
@@ -73,26 +73,39 @@ for (const comparisonStatus of ["behind", "diverged"]) {
7373 } ) ;
7474}
7575
76- function fixture ( { releases = [ nightly ( 7 ) ] , comparisonStatus = "ahead" } = { } ) {
76+ // `pages` holds the GraphQL release pages; `releases` is shorthand for one page.
77+ function fixture ( { releases = [ nightly ( 7 ) ] , pages = [ releases ] , comparisonStatus = "ahead" } = { } ) {
7778 const calls = [ ] ;
79+ const cursors = [ ] ;
7880 return {
7981 calls,
82+ cursors,
8083 options : {
8184 now,
8285 context : { repo : { owner : "example" , repo : "app" } , sha : "new" } ,
8386 core : { info ( ) { } } ,
8487 github : {
8588 rest : {
8689 repos : {
87- listReleases ( ) { } ,
8890 async compareCommitsWithBasehead ( params ) {
8991 calls . push ( params ) ;
9092 return { data : { status : comparisonStatus } } ;
9193 } ,
9294 } ,
9395 } ,
94- async paginate ( ) {
95- return releases ;
96+ async graphql ( _query , { owner, repo, cursor } ) {
97+ assert . deepEqual ( { owner, repo } , { owner : "example" , repo : "app" } ) ;
98+ cursors . push ( cursor ) ;
99+ const index = cursor === null ? 0 : Number ( cursor ) ;
100+ const hasNextPage = index + 1 < pages . length ;
101+ return {
102+ repository : {
103+ releases : {
104+ nodes : pages [ index ] ,
105+ pageInfo : { hasNextPage, endCursor : hasNextPage ? String ( index + 1 ) : null } ,
106+ } ,
107+ } ,
108+ } ;
96109 } ,
97110 } ,
98111 } ,
@@ -101,28 +114,65 @@ function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) {
101114
102115test ( "releases the first nightly when no nightly is published" , async ( ) => {
103116 const { options } = fixture ( {
104- releases : [ nightly ( 0 , { tag_name : "v1.0.0" } ) , nightly ( 0 , { draft : true } ) ] ,
117+ releases : [ nightly ( 0 , { tagName : "v1.0.0" } ) , nightly ( 0 , { isDraft : true } ) ] ,
105118 } ) ;
106119 assert . equal ( await shouldReleaseNightly ( options ) , true ) ;
107120} ) ;
108121
109122test ( "uses publication time, not release order or the tagged commit date" , async ( ) => {
110123 const { options } = fixture ( {
111- releases : [ nightly ( 10 ) , nightly ( 1 ) , nightly ( 20 , { tag_name : "nightly-v0.9.0" } ) ] ,
124+ releases : [ nightly ( 10 ) , nightly ( 1 ) , nightly ( 20 , { tagName : "nightly-v0.9.0" } ) ] ,
125+ } ) ;
126+ assert . equal ( await shouldReleaseNightly ( options ) , false ) ;
127+ } ) ;
128+
129+ test ( "the six-hour gap ends exactly six hours after publication" , async ( ) => {
130+ const atBoundary = fixture ( { releases : [ nightly ( 6 ) ] } ) ;
131+ assert . equal ( await shouldReleaseNightly ( atBoundary . options ) , true ) ;
132+ const justInside = fixture ( { releases : [ nightly ( 6 - 1 / 3_600_000 ) ] } ) ;
133+ assert . equal ( await shouldReleaseNightly ( justInside . options ) , false ) ;
134+ assert . equal ( justInside . calls . length , 0 ) ;
135+ } ) ;
136+
137+ test ( "stops reading releases once a nightly inside the gap is seen" , async ( ) => {
138+ const { options, cursors } = fixture ( { pages : [ [ nightly ( 10 ) , nightly ( 2 ) ] , [ nightly ( 30 ) ] ] } ) ;
139+ assert . equal ( await shouldReleaseNightly ( options ) , false ) ;
140+ assert . deepEqual ( cursors , [ null ] ) ;
141+ } ) ;
142+
143+ test ( "reads every page when no nightly seen so far settles the gap" , async ( ) => {
144+ const { options, cursors, calls } = fixture ( {
145+ pages : [
146+ [ nightly ( 10 ) , nightly ( 0 , { tagName : "v1.0.0" } ) ] ,
147+ [ nightly ( 1 , { tagName : "nightly-v0.9.0" } ) ] ,
148+ ] ,
112149 } ) ;
113150 assert . equal ( await shouldReleaseNightly ( options ) , false ) ;
151+ assert . deepEqual ( cursors , [ null , "1" ] ) ;
152+ assert . equal ( calls . length , 0 ) ;
153+ } ) ;
154+
155+ test ( "compares against the newest nightly across pages once the gap has passed" , async ( ) => {
156+ const { options, calls } = fixture ( {
157+ pages : [ [ nightly ( 10 , { tagName : "v1.0.1-nightly.20260905.100" } ) ] , [ nightly ( 8 ) ] ] ,
158+ } ) ;
159+ assert . equal ( await shouldReleaseNightly ( options ) , true ) ;
160+ assert . deepEqual (
161+ calls . map ( ( call ) => call . basehead ) ,
162+ [ "v1.0.1-nightly.20260905.123...new" ] ,
163+ ) ;
114164} ) ;
115165
116166test ( "ignores stable releases and drafts when checking the gap" , async ( ) => {
117167 const { options } = fixture ( {
118- releases : [ nightly ( 0 , { tag_name : "v1.0.0" } ) , nightly ( 0 , { draft : true } ) , nightly ( 7 ) ] ,
168+ releases : [ nightly ( 0 , { tagName : "v1.0.0" } ) , nightly ( 0 , { isDraft : true } ) , nightly ( 7 ) ] ,
119169 } ) ;
120170 assert . equal ( await shouldReleaseNightly ( options ) , true ) ;
121171} ) ;
122172
123173test ( "fails instead of releasing when GitHub cannot supply release state" , async ( ) => {
124174 const { options } = fixture ( ) ;
125- options . github . paginate = async ( ) => {
175+ options . github . graphql = async ( ) => {
126176 throw new Error ( "GitHub unavailable" ) ;
127177 } ;
128178 await assert . rejects ( shouldReleaseNightly ( options ) , / G i t H u b u n a v a i l a b l e / ) ;
@@ -137,9 +187,9 @@ for (const status of ["behind", "diverged"]) {
137187
138188const { resolveLatestNightlyCommit } = require ( "./check-nightly-release.cjs" ) ;
139189
140- function nightlyCommitFixture ( { releases, commitSha = "abc123" } ) {
190+ function nightlyCommitFixture ( { releases, pages , commitSha = "abc123" } ) {
141191 const refs = [ ] ;
142- const { options } = fixture ( { releases } ) ;
192+ const { options } = fixture ( { releases, pages } ) ;
143193 options . github . rest . repos . getCommit = async ( { ref } ) => {
144194 refs . push ( ref ) ;
145195 return { data : { sha : commitSha } } ;
@@ -150,10 +200,10 @@ function nightlyCommitFixture({ releases, commitSha = "abc123" }) {
150200test ( "stable releases resolve the commit of the newest published nightly" , async ( ) => {
151201 const { options, refs } = nightlyCommitFixture ( {
152202 releases : [
153- nightly ( 10 , { tag_name : "v1.0.1-nightly.20260905.100" } ) ,
154- nightly ( 1 , { tag_name : "v1.0.1-nightly.20260905.123" } ) ,
155- nightly ( 0 , { tag_name : "v1.0.0" } ) ,
156- nightly ( 0 , { draft : true , tag_name : "v1.0.1-nightly.20260905.999" } ) ,
203+ nightly ( 10 , { tagName : "v1.0.1-nightly.20260905.100" } ) ,
204+ nightly ( 1 , { tagName : "v1.0.1-nightly.20260905.123" } ) ,
205+ nightly ( 0 , { tagName : "v1.0.0" } ) ,
206+ nightly ( 0 , { isDraft : true , tagName : "v1.0.1-nightly.20260905.999" } ) ,
157207 ] ,
158208 commitSha : "deadbeef" ,
159209 } ) ;
@@ -167,7 +217,23 @@ test("stable releases resolve the commit of the newest published nightly", async
167217
168218test ( "stable releases derive the version from legacy nightly tags" , async ( ) => {
169219 const { options } = nightlyCommitFixture ( {
170- releases : [ nightly ( 1 , { tag_name : "nightly-v0.9.0-nightly.20260905.5" } ) ] ,
220+ releases : [ nightly ( 1 , { tagName : "nightly-v0.9.0-nightly.20260905.5" } ) ] ,
171221 } ) ;
172222 assert . equal ( ( await resolveLatestNightlyCommit ( options ) ) . version , "0.9.0" ) ;
173223} ) ;
224+
225+ test ( "stable releases read every page, even after a recent nightly" , async ( ) => {
226+ const { options, refs } = nightlyCommitFixture ( {
227+ pages : [
228+ [ nightly ( 2 , { tagName : "v1.0.1-nightly.20260905.100" } ) ] ,
229+ [ nightly ( 1 , { tagName : "v1.0.1-nightly.20260905.123" } ) ] ,
230+ ] ,
231+ } ) ;
232+ assert . equal ( ( await resolveLatestNightlyCommit ( options ) ) . tag , "v1.0.1-nightly.20260905.123" ) ;
233+ assert . deepEqual ( refs , [ "v1.0.1-nightly.20260905.123" ] ) ;
234+ } ) ;
235+
236+ test ( "stable releases fail when no nightly is published" , async ( ) => {
237+ const { options } = nightlyCommitFixture ( { releases : [ nightly ( 1 , { tagName : "v1.0.0" } ) ] } ) ;
238+ await assert . rejects ( resolveLatestNightlyCommit ( options ) , / N o p u b l i s h e d n i g h t l y f o u n d / ) ;
239+ } ) ;
0 commit comments