@@ -9,12 +9,49 @@ const stderr = (message) => process.stderr.write(`${message}\n`);
99const printJson = ( value ) => process . stdout . write ( `${ JSON . stringify ( value , null , 2 ) } \n` ) ;
1010
1111class ActionError extends Error {
12- constructor ( message ) {
12+ constructor ( message , options = { } ) {
1313 super ( message ) ;
1414 this . name = 'ActionError' ;
15+ this . statusCode =
16+ typeof options . statusCode === 'number' && Number . isFinite ( options . statusCode )
17+ ? options . statusCode
18+ : undefined ;
19+ this . code =
20+ typeof options . code === 'string' && options . code . trim ( ) . length > 0
21+ ? options . code . trim ( ) . toUpperCase ( )
22+ : undefined ;
1523 }
1624}
1725
26+ const RETRYABLE_STATUS_CODES = new Set ( [ 408 , 425 , 429 , 500 , 502 , 503 , 504 ] ) ;
27+ const RETRYABLE_ERROR_CODES = new Set ( [
28+ 'ECONNABORTED' ,
29+ 'ECONNRESET' ,
30+ 'ECONNREFUSED' ,
31+ 'ENOTFOUND' ,
32+ 'EAI_AGAIN' ,
33+ 'ETIMEDOUT' ,
34+ 'ERR_NETWORK' ,
35+ 'UND_ERR_CONNECT_TIMEOUT' ,
36+ 'UND_ERR_CONNECT_ERROR' ,
37+ ] ) ;
38+ const RETRYABLE_ERROR_MARKERS = [
39+ 'gateway time-out' ,
40+ 'gateway timeout' ,
41+ 'timed out' ,
42+ 'timeout' ,
43+ 'service unavailable' ,
44+ 'temporarily unavailable' ,
45+ 'too many requests' ,
46+ 'rate limit' ,
47+ 'network error' ,
48+ 'fetch failed' ,
49+ 'connection reset' ,
50+ ] ;
51+ const INTEGER_VERSION_PATTERN = / ^ \d + $ / ;
52+ const SEMVER_VERSION_PATTERN = / ^ ( \d + ) \. ( \d + ) \. ( \d + ) (?: [ - + ] [ 0 - 9 A - Z a - z . - ] + ) ? $ / ;
53+ const SEMVER_PRERELEASE_PATTERN = / ^ \d + \. \d + \. \d + - [ 0 - 9 A - Z a - z . - ] + (?: \+ [ 0 - 9 A - Z a - z . - ] + ) ? $ / ;
54+
1855const getEnv = ( name , fallback = '' ) => {
1956 const value = process . env [ name ] ;
2057 return typeof value === 'string' ? value : fallback ;
@@ -33,6 +70,30 @@ const parseNumber = (value, fallback) => {
3370 return Number . isFinite ( parsed ) && parsed > 0 ? parsed : fallback ;
3471} ;
3572
73+ const isStableRegistryVersion = ( value ) => {
74+ const normalized = String ( value ?? '' ) . trim ( ) ;
75+ if ( ! normalized ) {
76+ return false ;
77+ }
78+ if ( INTEGER_VERSION_PATTERN . test ( normalized ) ) {
79+ return true ;
80+ }
81+ if ( SEMVER_PRERELEASE_PATTERN . test ( normalized ) ) {
82+ return false ;
83+ }
84+ return SEMVER_VERSION_PATTERN . test ( normalized ) ;
85+ } ;
86+
87+ const isProductionRegistryBase = ( value ) => {
88+ try {
89+ const url = new URL ( String ( value ?? '' ) . trim ( ) ) ;
90+ const hostname = url . hostname . toLowerCase ( ) ;
91+ return hostname === 'hol.org' || hostname === 'registry.hashgraphonline.com' ;
92+ } catch {
93+ return false ;
94+ }
95+ } ;
96+
3697const guessMimeType = ( filePath ) => {
3798 const lower = filePath . toLowerCase ( ) ;
3899 if ( lower . endsWith ( '.md' ) || lower . endsWith ( '.markdown' ) ) {
@@ -110,6 +171,44 @@ const summarizeErrorBody = async (response) => {
110171 }
111172} ;
112173
174+ const sleep = ( delayMs ) =>
175+ new Promise ( ( resolve ) => {
176+ setTimeout ( resolve , delayMs ) ;
177+ } ) ;
178+
179+ const extractErrorCode = ( error ) => {
180+ if ( ! error || typeof error !== 'object' ) {
181+ return '' ;
182+ }
183+ if ( typeof error . code === 'string' ) {
184+ return error . code . trim ( ) . toUpperCase ( ) ;
185+ }
186+ if ( error . cause && typeof error . cause === 'object' && typeof error . cause . code === 'string' ) {
187+ return error . cause . code . trim ( ) . toUpperCase ( ) ;
188+ }
189+ return '' ;
190+ } ;
191+
192+ const isRetryableRequestError = ( error ) => {
193+ if ( error instanceof ActionError && typeof error . statusCode === 'number' ) {
194+ if ( RETRYABLE_STATUS_CODES . has ( error . statusCode ) ) {
195+ return true ;
196+ }
197+ if ( error . statusCode >= 400 && error . statusCode < 500 ) {
198+ return false ;
199+ }
200+ }
201+
202+ const code = extractErrorCode ( error ) ;
203+ if ( code && RETRYABLE_ERROR_CODES . has ( code ) ) {
204+ return true ;
205+ }
206+
207+ const message =
208+ error instanceof Error ? error . message . toLowerCase ( ) : String ( error ?? '' ) . toLowerCase ( ) ;
209+ return RETRYABLE_ERROR_MARKERS . some ( ( marker ) => message . includes ( marker ) ) ;
210+ } ;
211+
113212const requestJson = async ( params ) => {
114213 const {
115214 method,
@@ -118,34 +217,69 @@ const requestJson = async (params) => {
118217 body,
119218 signal,
120219 } = params ;
121- const response = await fetch ( url , {
122- method,
123- headers : {
124- 'content-type' : 'application/json' ,
125- 'x-api-key' : apiKey ,
126- } ,
127- ...( body ? { body : JSON . stringify ( body ) } : { } ) ,
128- signal,
129- } ) ;
220+ let response ;
221+ try {
222+ response = await fetch ( url , {
223+ method,
224+ headers : {
225+ 'content-type' : 'application/json' ,
226+ 'x-api-key' : apiKey ,
227+ } ,
228+ ...( body ? { body : JSON . stringify ( body ) } : { } ) ,
229+ signal,
230+ } ) ;
231+ } catch ( error ) {
232+ throw new ActionError (
233+ `${ method } ${ url } failed: ${ error instanceof Error ? error . message : String ( error ) } ` ,
234+ {
235+ code : extractErrorCode ( error ) ,
236+ } ,
237+ ) ;
238+ }
130239 if ( ! response . ok ) {
131240 const bodySummary = await summarizeErrorBody ( response ) ;
132241 throw new ActionError (
133242 `${ method } ${ url } failed with ${ response . status } ${ bodySummary ? `: ${ bodySummary } ` : '' } ` ,
243+ {
244+ statusCode : response . status ,
245+ } ,
134246 ) ;
135247 }
136248 return response . json ( ) ;
137249} ;
138250
251+ const requestJsonWithRetry = async ( params ) => {
252+ const attempts = Number . isFinite ( params . attempts ) && params . attempts > 0 ? Math . floor ( params . attempts ) : 1 ;
253+ let lastError = null ;
254+ for ( let attempt = 1 ; attempt <= attempts ; attempt += 1 ) {
255+ try {
256+ return await requestJson ( params ) ;
257+ } catch ( error ) {
258+ lastError = error ;
259+ if ( attempt >= attempts || ! isRetryableRequestError ( error ) ) {
260+ throw error ;
261+ }
262+ const delayMs = Math . min ( 10_000 , 1_000 * attempt ) ;
263+ stderr (
264+ `Transient request failure on ${ params . method } ${ params . url } ; retrying in ${ delayMs } ms (retry ${ attempt } /${ attempts - 1 } ).` ,
265+ ) ;
266+ await sleep ( delayMs ) ;
267+ }
268+ }
269+ throw lastError instanceof Error ? lastError : new ActionError ( 'Request failed' ) ;
270+ } ;
271+
139272const findExistingSkillVersion = async ( params ) => {
140273 const { apiBaseUrl, apiKey, name, version } = params ;
141- const response = await requestJson ( {
274+ const response = await requestJsonWithRetry ( {
142275 method : 'GET' ,
143276 url : buildApiUrl ( apiBaseUrl , '/skills' , {
144277 name,
145278 version,
146279 limit : 20 ,
147280 } ) ,
148281 apiKey,
282+ attempts : 3 ,
149283 } ) ;
150284
151285 const items = Array . isArray ( response ?. items ) ? response . items : [ ] ;
@@ -396,6 +530,10 @@ const run = async () => {
396530 const skillDirInput = getEnv ( 'INPUT_SKILL_DIR' ) ;
397531 const overrideName = getEnv ( 'INPUT_NAME' ) ;
398532 const overrideVersion = getEnv ( 'INPUT_VERSION' ) ;
533+ const allowNonstableProductionVersion = toBoolean (
534+ getEnv ( 'INPUT_ALLOW_NONSTABLE_PRODUCTION_VERSION' ) ,
535+ false ,
536+ ) ;
399537 const stampRepoCommit = toBoolean ( getEnv ( 'INPUT_STAMP_REPO_COMMIT' ) , true ) ;
400538 const pollTimeoutMs = parseNumber ( getEnv ( 'INPUT_POLL_TIMEOUT_MS' ) , 720000 ) ;
401539 const pollIntervalMs = parseNumber ( getEnv ( 'INPUT_POLL_INTERVAL_MS' ) , 4000 ) ;
@@ -494,6 +632,21 @@ const run = async () => {
494632 if ( ! skillDescription ) {
495633 throw new ActionError ( 'skill.json must include description.' ) ;
496634 }
635+ const nonstableVersion = ! isStableRegistryVersion ( skillVersion ) ;
636+ if (
637+ nonstableVersion &&
638+ isProductionRegistryBase ( apiBaseUrl ) &&
639+ ! allowNonstableProductionVersion
640+ ) {
641+ throw new ActionError (
642+ `Refusing to publish ${ skillName } @${ skillVersion } to the production registry because it is not a stable release version. Use staging instead, or set allow-nonstable-production-version=true if this is intentional.` ,
643+ ) ;
644+ }
645+ if ( nonstableVersion ) {
646+ stderr (
647+ `Publishing ${ skillName } @${ skillVersion } as a custom prerelease version. The registry will not use this release as the public stable default unless it is explicitly recommended.` ,
648+ ) ;
649+ }
497650
498651 if ( mode === 'publish' ) {
499652 const existingVersion = await findExistingSkillVersion ( {
@@ -576,10 +729,11 @@ const run = async () => {
576729 let maxTotalSizeBytes = 0 ;
577730 let allowedMimeTypes = null ;
578731 if ( mode === 'publish' || mode === 'quote' ) {
579- const config = await requestJson ( {
732+ const config = await requestJsonWithRetry ( {
580733 method : 'GET' ,
581734 url : buildApiUrl ( apiBaseUrl , '/skills/config' ) ,
582735 apiKey,
736+ attempts : 3 ,
583737 } ) ;
584738 maxFiles = Number ( config ?. maxFiles ?? 0 ) ;
585739 maxTotalSizeBytes = Number ( config ?. maxTotalSizeBytes ?? 0 ) ;
@@ -662,14 +816,15 @@ const run = async () => {
662816 return ;
663817 }
664818
665- const quote = await requestJson ( {
819+ const quote = await requestJsonWithRetry ( {
666820 method : 'POST' ,
667821 url : buildApiUrl ( apiBaseUrl , '/skills/quote' ) ,
668822 apiKey,
669823 body : {
670824 files,
671825 ...( accountId ? { accountId } : { } ) ,
672826 } ,
827+ attempts : 3 ,
673828 } ) ;
674829
675830 const quoteId = String ( quote ?. quoteId ?? '' ) . trim ( ) ;
@@ -741,10 +896,11 @@ const run = async () => {
741896 let lastStatus = '' ;
742897 let completedJob = null ;
743898 while ( Date . now ( ) - startedAt < pollTimeoutMs ) {
744- const job = await requestJson ( {
899+ const job = await requestJsonWithRetry ( {
745900 method : 'GET' ,
746901 url : buildApiUrl ( apiBaseUrl , `/skills/jobs/${ encodeURIComponent ( jobId ) } ` , accountId ? { accountId } : null ) ,
747902 apiKey,
903+ attempts : 3 ,
748904 } ) ;
749905 const status = String ( job ?. status ?? '' ) . trim ( ) ;
750906 if ( status && status !== lastStatus ) {
0 commit comments