@@ -11,22 +11,18 @@ const DETROIT_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
1111
1212const DEFAULT_TIMEOUT_MS = 20 * 60 * 1000 ;
1313const DEFAULT_POLL_INTERVAL_MS = 20 * 1000 ;
14- const MAX_REQUEST_ATTEMPTS = 3 ;
15- const RETRYABLE_NETWORK_CODES = new Set ( [
16- 'EAI_AGAIN' ,
17- 'ECONNREFUSED' ,
18- 'ECONNRESET' ,
19- 'EHOSTUNREACH' ,
20- 'ENETUNREACH' ,
21- 'EPIPE' ,
22- 'ESOCKETTIMEDOUT' ,
23- 'ETIMEDOUT' ,
24- 'UND_ERR_CONNECT_TIMEOUT' ,
25- 'UND_ERR_SOCKET' ,
26- ] ) ;
27- const RETRYABLE_HTTP_STATUSES = new Set ( [ 408 , 425 , 429 ] ) ;
28- const RETRY_BASE_DELAY_MS = 1000 ;
29- const MAX_RETRY_DELAY_MS = 10000 ;
14+ const TEMPORARY_NETWORK_ERRORS = new Set ( [ 'EAI_AGAIN' , 'ECONNRESET' , 'ETIMEDOUT' ] ) ;
15+
16+ function retryableReadFailure ( error ) {
17+ if ( [ 500 , 502 , 503 , 504 ] . includes ( error ?. status ) ) return `HTTP ${ error . status } ` ;
18+
19+ const visited = new Set ( ) ;
20+ for ( let cause = error ; cause && ! visited . has ( cause ) ; cause = cause . cause ) {
21+ visited . add ( cause ) ;
22+ if ( TEMPORARY_NETWORK_ERRORS . has ( cause . code ) ) return cause . code ;
23+ }
24+ return null ;
25+ }
3026
3127function detroitCalendarDate ( timestamp ) {
3228 if ( typeof timestamp !== 'string' ) return null ;
@@ -53,96 +49,6 @@ function repositoryMatches(run, expectedFullName) {
5349 && repository . toLowerCase ( ) === expectedFullName . toLowerCase ( ) ;
5450}
5551
56- function errorStatus ( error ) {
57- const status = error ?. status ?? error ?. response ?. status ;
58- return Number . isInteger ( status ) ? status : null ;
59- }
60-
61- function errorCode ( error ) {
62- let current = error ;
63- for ( let depth = 0 ; current && depth < 4 ; depth += 1 ) {
64- if ( typeof current . code === 'string' ) return current . code ;
65- current = current . cause ;
66- }
67-
68- // Octokit may preserve a native network error only in the wrapped message.
69- const message = String ( error ?. message ?? '' ) ;
70- return [ ...RETRYABLE_NETWORK_CODES ] . find ( ( candidate ) => (
71- new RegExp ( `\\b${ candidate } \\b` ) . test ( message )
72- ) ) ?? null ;
73- }
74-
75- function responseHeader ( error , name ) {
76- const headers = error ?. response ?. headers ?? error ?. headers ;
77- if ( ! headers ) return undefined ;
78- return headers [ name . toLowerCase ( ) ] ?? headers . get ?. ( name . toLowerCase ( ) ) ;
79- }
80-
81- function isTransientRequestError ( error ) {
82- const status = errorStatus ( error ) ;
83- if (
84- RETRYABLE_HTTP_STATUSES . has ( status )
85- || ( status !== null && status >= 500 )
86- ) {
87- return true ;
88- }
89-
90- if (
91- status === 403
92- && ( responseHeader ( error , 'retry-after' ) !== undefined
93- || responseHeader ( error , 'x-ratelimit-remaining' ) === '0' )
94- ) {
95- return true ;
96- }
97-
98- const code = errorCode ( error ) ;
99- if ( code && RETRYABLE_NETWORK_CODES . has ( code ) ) return true ;
100-
101- return false ;
102- }
103-
104- function retryDelayMs ( error , attempt ) {
105- const retryAfter = responseHeader ( error , 'retry-after' ) ;
106- if ( retryAfter !== undefined ) {
107- const seconds = Number ( retryAfter ) ;
108- const requestedDelay = Number . isFinite ( seconds )
109- ? seconds * 1000
110- : Date . parse ( retryAfter ) - Date . now ( ) ;
111- if ( Number . isFinite ( requestedDelay ) ) {
112- return Math . min ( MAX_RETRY_DELAY_MS , Math . max ( 0 , requestedDelay ) ) ;
113- }
114- }
115-
116- const resetAt = responseHeader ( error , 'x-ratelimit-reset' ) ;
117- if ( resetAt !== undefined && responseHeader ( error , 'x-ratelimit-remaining' ) === '0' ) {
118- const requestedDelay = Number ( resetAt ) * 1000 - Date . now ( ) ;
119- if ( Number . isFinite ( requestedDelay ) ) {
120- return Math . min ( MAX_RETRY_DELAY_MS , Math . max ( 0 , requestedDelay ) ) ;
121- }
122- }
123-
124- return Math . min ( MAX_RETRY_DELAY_MS , RETRY_BASE_DELAY_MS * ( 2 ** ( attempt - 1 ) ) ) ;
125- }
126-
127- async function requestWithRetry ( method , request , { sleep, core } ) {
128- for ( let attempt = 1 ; ; attempt += 1 ) {
129- try {
130- return await request ( ) ;
131- } catch ( error ) {
132- if ( attempt >= MAX_REQUEST_ATTEMPTS || ! isTransientRequestError ( error ) ) {
133- throw error ;
134- }
135-
136- const delayMs = retryDelayMs ( error , attempt ) ;
137- const reason = errorStatus ( error ) ?? errorCode ( error ) ?? 'transient network error' ;
138- core . info (
139- `GitHub Actions ${ method } failed transiently (${ reason } ); retrying attempt ${ attempt + 1 } /${ MAX_REQUEST_ATTEMPTS } in ${ delayMs } ms.` ,
140- ) ;
141- await sleep ( delayMs ) ;
142- }
143- }
144- }
145-
14652function requireValidTiming ( timeoutMs , pollIntervalMs ) {
14753 if ( ! Number . isFinite ( timeoutMs ) || timeoutMs < 0 ) {
14854 throw new TypeError ( 'timeoutMs must be a finite non-negative number' ) ;
@@ -174,28 +80,50 @@ async function waitForDailyCI({
17480 const expectedFullName = `${ owner } /${ repo } ` ;
17581 const expectedSha = context ?. sha ;
17682 const deploymentRunId = context ?. runId ;
83+ const deploymentEvent = context ?. eventName ;
17784
17885 if ( ! owner || ! repo || ! expectedSha || ! deploymentRunId ) {
17986 throw new TypeError ( 'GitHub repository, SHA, and workflow run context are required' ) ;
18087 }
181- const deploymentEvent = context . eventName ;
182- if (
183- ! [ 'schedule' , 'workflow_dispatch' ] . includes ( deploymentEvent )
184- || context . ref !== 'refs/heads/main'
185- ) {
88+ if ( ! [ 'schedule' , 'workflow_dispatch' ] . includes ( deploymentEvent ) || context . ref !== 'refs/heads/main' ) {
18689 throw new Error ( 'CI wait is only valid for a scheduled or manually dispatched run on main' ) ;
18790 }
18891
18992 const startedAt = now ( ) ;
190- const deploymentResponse = await requestWithRetry (
191- 'getWorkflowRun' ,
192- ( ) => github . rest . actions . getWorkflowRun ( {
193- owner,
194- repo,
195- run_id : deploymentRunId ,
196- } ) ,
197- { sleep, core } ,
198- ) ;
93+ // Retry read-only transport failures within the same deadline as CI polling.
94+ // Every recovered response still has to pass the repository/SHA/date gates.
95+ async function readGitHub ( label , request ) {
96+ let retrying = false ;
97+ const timedOut = ( cause ) => new Error (
98+ `Timed out after ${ timeoutMs } ms reading GitHub ${ label } ; refusing deployment` ,
99+ { cause } ,
100+ ) ;
101+ while ( true ) {
102+ const elapsedMs = now ( ) - startedAt ;
103+ // A zero timeout still permits the original immediate lookup, but no retry.
104+ if ( elapsedMs > timeoutMs || ( retrying && elapsedMs >= timeoutMs ) ) throw timedOut ( ) ;
105+ try {
106+ const response = await request ( ) ;
107+ if ( now ( ) - startedAt > timeoutMs ) throw timedOut ( ) ;
108+ return response ;
109+ } catch ( error ) {
110+ const reason = retryableReadFailure ( error ) ;
111+ if ( ! reason ) throw error ;
112+
113+ const remainingMs = timeoutMs - ( now ( ) - startedAt ) ;
114+ if ( remainingMs <= 0 ) throw timedOut ( error ) ;
115+ core . info ( `Temporary GitHub ${ label } read failure (${ reason } ); retrying within the CI wait deadline.` ) ;
116+ await sleep ( Math . min ( pollIntervalMs , remainingMs ) ) ;
117+ retrying = true ;
118+ }
119+ }
120+ }
121+
122+ const deploymentResponse = await readGitHub ( 'deployment run' , ( ) => github . rest . actions . getWorkflowRun ( {
123+ owner,
124+ repo,
125+ run_id : deploymentRunId ,
126+ } ) ) ;
199127 const deploymentRun = deploymentResponse ?. data ;
200128
201129 if (
@@ -215,18 +143,14 @@ async function waitForDailyCI({
215143 const ciRunKind = deploymentEvent === 'schedule' ? 'scheduled' : 'manually dispatched' ;
216144
217145 while ( true ) {
218- const response = await requestWithRetry (
219- 'listWorkflowRuns' ,
220- ( ) => github . rest . actions . listWorkflowRuns ( {
221- owner,
222- repo,
223- workflow_id : 'ci.yml' ,
224- event : deploymentEvent ,
225- branch : 'main' ,
226- per_page : 100 ,
227- } ) ,
228- { sleep, core } ,
229- ) ;
146+ const response = await readGitHub ( `${ ciRunKind } CI runs` , ( ) => github . rest . actions . listWorkflowRuns ( {
147+ owner,
148+ repo,
149+ workflow_id : 'ci.yml' ,
150+ event : deploymentEvent ,
151+ branch : 'main' ,
152+ per_page : 100 ,
153+ } ) ) ;
230154 const workflowRuns = response ?. data ?. workflow_runs ;
231155 if ( ! Array . isArray ( workflowRuns ) ) {
232156 throw new Error ( 'GitHub returned an invalid CI workflow-runs response' ) ;
@@ -245,7 +169,7 @@ async function waitForDailyCI({
245169 ) ) ;
246170 if ( unsuccessfulRun ) {
247171 throw new Error (
248- `Scheduled CI run ${ unsuccessfulRun . id } completed with conclusion ${ unsuccessfulRun . conclusion ?? 'unknown' } ; refusing deployment` ,
172+ `${ ciRunKind [ 0 ] . toUpperCase ( ) } ${ ciRunKind . slice ( 1 ) } CI run ${ unsuccessfulRun . id } completed with conclusion ${ unsuccessfulRun . conclusion ?? 'unknown' } ; refusing deployment` ,
249173 ) ;
250174 }
251175
0 commit comments