Skip to content

Commit 6b37048

Browse files
authored
Merge pull request #4 from hashgraph-online/feat/safe-skill-package-discovery
feat: guard production skill version publishes
2 parents c182a4a + 4090886 commit 6b37048

File tree

6 files changed

+212
-16
lines changed

6 files changed

+212
-16
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,8 @@ This action exists to make that publish step deterministic and automated in CI.
311311
| `api-base-url` | No | `https://hol.org/registry/api/v1` | Broker base URL (`.../registry` or `.../registry/api/v1`). |
312312
| `account-id` | No | - | Optional Hedera account ID for publish authorization edge cases. |
313313
| `name` | No | - | Optional skill name override for `skill.json`. |
314-
| `version` | No | - | Optional version override for `skill.json`. |
314+
| `version` | No | - | Optional version override for `skill.json`. Non-stable overrides are blocked on production by default. |
315+
| `allow-nonstable-production-version` | No | `false` | Explicitly permits publishing a non-stable custom version to the production registry. |
315316
| `stamp-repo-commit` | No | `true` | Stamp `repo` and `commit` metadata into payload. |
316317
| `poll-timeout-ms` | No | `720000` | Max time to wait for publish job completion. |
317318
| `poll-interval-ms` | No | `4000` | Interval between publish job status polls. |

action.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ inputs:
2424
version:
2525
description: "Optional version override for skill.json"
2626
required: false
27+
allow-nonstable-production-version:
28+
description: "When true, permits publishing a non-stable custom version to the production registry"
29+
required: false
30+
default: "false"
2731
stamp-repo-commit:
2832
description: "When true, writes repo and commit fields into the publish payload"
2933
required: false
@@ -173,6 +177,7 @@ runs:
173177
INPUT_SKILL_DIR: ${{ inputs.skill-dir }}
174178
INPUT_NAME: ${{ inputs.name }}
175179
INPUT_VERSION: ${{ inputs.version }}
180+
INPUT_ALLOW_NONSTABLE_PRODUCTION_VERSION: ${{ inputs.allow-nonstable-production-version }}
176181
INPUT_STAMP_REPO_COMMIT: ${{ inputs.stamp-repo-commit }}
177182
INPUT_POLL_TIMEOUT_MS: ${{ inputs.poll-timeout-ms }}
178183
INPUT_POLL_INTERVAL_MS: ${{ inputs.poll-interval-ms }}

bin/lib/repo-commands.mjs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@ function buildManualWorkflow(skillDir, annotate) {
140140
on:
141141
workflow_dispatch:
142142
inputs:
143+
publish_target:
144+
type: choice
145+
required: true
146+
default: staging
147+
options:
148+
- staging
149+
- production
143150
version:
144151
type: string
145152
required: false
@@ -153,9 +160,19 @@ jobs:
153160
issues: write
154161
steps:
155162
- uses: actions/checkout@v4
163+
- name: Resolve broker API URL
164+
id: target
165+
shell: bash
166+
run: |
167+
if [[ "\${{ inputs.publish_target }}" == "staging" ]]; then
168+
echo "api_base_url=https://registry-staging.hol.org/registry/api/v1" >> "$GITHUB_OUTPUT"
169+
else
170+
echo "api_base_url=https://hol.org/registry/api/v1" >> "$GITHUB_OUTPUT"
171+
fi
156172
- name: Publish skill package
157173
uses: hashgraph-online/skill-publish@v1
158174
with:
175+
api-base-url: \${{ steps.target.outputs.api_base_url }}
159176
api-key: \${{ secrets.RB_API_KEY }}
160177
skill-dir: ${skillDir}
161178
version: \${{ inputs.version }}

entrypoint.mjs

Lines changed: 170 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,49 @@ const stderr = (message) => process.stderr.write(`${message}\n`);
99
const printJson = (value) => process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
1010

1111
class 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-9A-Za-z.-]+)?$/;
53+
const SEMVER_PRERELEASE_PATTERN = /^\d+\.\d+\.\d+-[0-9A-Za-z.-]+(?:\+[0-9A-Za-z.-]+)?$/;
54+
1855
const 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+
3697
const 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+
113212
const 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+
139272
const 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) {

examples/workflows/publish-manual.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ on:
77
type: string
88
required: true
99
default: skills/my-skill
10+
publish_target:
11+
type: choice
12+
required: true
13+
default: staging
14+
options:
15+
- staging
16+
- production
1017
version:
1118
type: string
1219
required: false
@@ -25,9 +32,19 @@ jobs:
2532
contents: read
2633
steps:
2734
- uses: actions/checkout@v4
35+
- name: Resolve broker API URL
36+
id: target
37+
shell: bash
38+
run: |
39+
if [[ "${{ inputs.publish_target }}" == "staging" ]]; then
40+
echo "api_base_url=https://registry-staging.hol.org/registry/api/v1" >> "$GITHUB_OUTPUT"
41+
else
42+
echo "api_base_url=https://hol.org/registry/api/v1" >> "$GITHUB_OUTPUT"
43+
fi
2844
- name: Publish skill package
2945
uses: hashgraph-online/skill-publish@v1
3046
with:
47+
api-base-url: ${{ steps.target.outputs.api_base_url }}
3148
api-key: ${{ secrets.RB_API_KEY }}
3249
skill-dir: ${{ inputs.skill_dir }}
3350
version: ${{ inputs.version }}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "skill-publish",
3-
"version": "1.0.3",
3+
"version": "1.0.4",
44
"description": "Publish trustless, immutable, on-chain skill releases via the HOL Registry Broker.",
55
"type": "module",
66
"homepage": "https://hol.org/registry/skills/publish",

0 commit comments

Comments
 (0)