Skip to content

Commit 6f04299

Browse files
authored
fix(skills): skip the index on GitHub transport errors instead of failing the build (#108)
PR #95 rewrote the generator and dropped the deploy-safe error handling from PR #89, so any GitHub outage, 5xx, or exhausted rate limit aborted the whole production build via bun run generate. Restore the intended two-tier split: - Transport errors (fetch network failures, non-ok GitHub responses, rate limits) are classified as GitHubTransportError. The entrypoint warns and exits 0, leaving the index absent for that deploy. - Schema, packaging, validation, and filesystem errors still throw and fail the build, so a malformed index can never ship. The script writes nothing before every artifact validates, so skipping on a transport error leaves no partial output behind.
1 parent 9437e27 commit 6f04299

2 files changed

Lines changed: 95 additions & 8 deletions

File tree

scripts/generate-agent-skills-index.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env bun
22
// ABOUTME: Generates the Agent Skills discovery index and complete skill archives.
3-
// ABOUTME: Reads one commit-pinned steel-dev/skills snapshot and fails the build on errors.
3+
// ABOUTME: Skips the index when GitHub is unreachable; fails the build on validation errors.
44
import { createHash } from 'node:crypto';
55
import * as fs from 'node:fs/promises';
66
import * as path from 'node:path';
@@ -282,6 +282,24 @@ export async function buildSkillArtifactsFromRepositoryArchive(
282282
return artifacts;
283283
}
284284

285+
/**
286+
* GitHub was unreachable or unhealthy: a network failure, a non-ok response,
287+
* or an exhausted rate limit. The entrypoint treats these as skippable because
288+
* a docs deploy should not hinge on GitHub; every other error stays fatal.
289+
*/
290+
export class GitHubTransportError extends Error {}
291+
292+
/** Fetches from GitHub, classifying network failures as transport errors. */
293+
export async function githubFetch(url: string, headers: Record<string, string>): Promise<Response> {
294+
try {
295+
return await fetch(url, { headers });
296+
} catch (error) {
297+
throw new GitHubTransportError(
298+
`Could not reach ${url}: ${error instanceof Error ? error.message : String(error)}`,
299+
);
300+
}
301+
}
302+
285303
function githubAuthorizationHeaders(): Record<string, string> {
286304
const token = process.env.GITHUB_TOKEN?.trim();
287305
return token ? { Authorization: `Bearer ${token}` } : {};
@@ -295,7 +313,7 @@ function githubApiHeaders(): Record<string, string> {
295313
};
296314
}
297315

298-
function githubRequestError(action: string, response: Response): Error {
316+
export function githubRequestError(action: string, response: Response): GitHubTransportError {
299317
const remaining = response.headers.get('x-ratelimit-remaining');
300318
const reset = response.headers.get('x-ratelimit-reset');
301319
let guidance = '';
@@ -310,13 +328,14 @@ function githubRequestError(action: string, response: Response): Error {
310328
guidance = `; GitHub API rate limit exhausted until ${resetAt}, set GITHUB_TOKEN`;
311329
}
312330

313-
return new Error(`${action}: GitHub returned ${response.status}${guidance}`);
331+
return new GitHubTransportError(`${action}: GitHub returned ${response.status}${guidance}`);
314332
}
315333

316334
async function resolveHeadCommit(): Promise<string> {
317-
const response = await fetch(`https://api.github.com/repos/${SKILLS_REPO}/commits/main`, {
318-
headers: githubApiHeaders(),
319-
});
335+
const response = await githubFetch(
336+
`https://api.github.com/repos/${SKILLS_REPO}/commits/main`,
337+
githubApiHeaders(),
338+
);
320339

321340
if (!response.ok) {
322341
throw githubRequestError(`Could not resolve ${SKILLS_REPO}@main`, response);
@@ -332,7 +351,7 @@ async function resolveHeadCommit(): Promise<string> {
332351

333352
async function fetchRepositoryArchive(commit: string): Promise<Buffer> {
334353
const url = `https://codeload.github.com/${SKILLS_REPO}/tar.gz/${commit}`;
335-
const response = await fetch(url, { headers: githubAuthorizationHeaders() });
354+
const response = await githubFetch(url, githubAuthorizationHeaders());
336355

337356
if (!response.ok) {
338357
throw githubRequestError(`Could not fetch ${SKILLS_REPO}@${commit}`, response);
@@ -364,5 +383,14 @@ async function main(): Promise<void> {
364383
}
365384

366385
if (import.meta.main) {
367-
await main();
386+
try {
387+
await main();
388+
} catch (error) {
389+
if (!(error instanceof GitHubTransportError)) throw error;
390+
// A docs deploy should not hinge on GitHub being reachable. Skipping leaves
391+
// the index absent, which is honest, rather than stale or unverifiable.
392+
// main() writes nothing before every artifact validates, so no partial
393+
// output can deploy; validation and filesystem errors rethrow above.
394+
console.warn(`⚠️ Skipped the Agent Skills index: ${error.message}`);
395+
}
368396
}

tests/agent-skills-index.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import {
77
AGENT_SKILLS_SCHEMA,
88
buildAgentSkillsIndex,
99
buildSkillArtifactsFromRepositoryArchive,
10+
GitHubTransportError,
11+
githubFetch,
12+
githubRequestError,
1013
MAX_SKILL_ARCHIVE_CONTENT_BYTES,
1114
type SkillArtifact,
1215
} from '../scripts/generate-agent-skills-index';
@@ -275,3 +278,59 @@ describe('buildAgentSkillsIndex', () => {
275278
expect(() => buildAgentSkillsIndex([])).toThrow(/skill/i);
276279
});
277280
});
281+
282+
describe('GitHub transport error classification', () => {
283+
test('classifies non-ok GitHub responses as skippable transport errors', () => {
284+
const error = githubRequestError(
285+
'Could not resolve steel-dev/skills@main',
286+
new Response(null, { status: 502 }),
287+
);
288+
289+
expect(error).toBeInstanceOf(GitHubTransportError);
290+
expect(error.message).toContain('502');
291+
});
292+
293+
test('classifies exhausted rate limits as skippable transport errors', () => {
294+
const error = githubRequestError(
295+
'Could not resolve steel-dev/skills@main',
296+
new Response(null, {
297+
status: 403,
298+
headers: { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': '1767225600' },
299+
}),
300+
);
301+
302+
expect(error).toBeInstanceOf(GitHubTransportError);
303+
expect(error.message).toContain('rate limit');
304+
});
305+
306+
test('classifies fetch network failures as skippable transport errors', async () => {
307+
const originalFetch = globalThis.fetch;
308+
globalThis.fetch = (() =>
309+
Promise.reject(new TypeError('Unable to connect'))) as typeof globalThis.fetch;
310+
311+
try {
312+
const error = await githubFetch('https://api.github.com/repos/steel-dev/skills', {}).catch(
313+
(caught: unknown) => caught,
314+
);
315+
316+
expect(error).toBeInstanceOf(GitHubTransportError);
317+
expect((error as Error).message).toContain('Unable to connect');
318+
} finally {
319+
globalThis.fetch = originalFetch;
320+
}
321+
});
322+
323+
test('validation errors are not classified as skippable transport errors', async () => {
324+
const indexError = await Promise.resolve()
325+
.then(() => buildAgentSkillsIndex([]))
326+
.catch((caught: unknown) => caught);
327+
const packagingError = await buildSkillArtifactsFromRepositoryArchive(
328+
await repositoryArchive({ skillMd: null }),
329+
).catch((caught: unknown) => caught);
330+
331+
expect(indexError).toBeInstanceOf(Error);
332+
expect(indexError).not.toBeInstanceOf(GitHubTransportError);
333+
expect(packagingError).toBeInstanceOf(Error);
334+
expect(packagingError).not.toBeInstanceOf(GitHubTransportError);
335+
});
336+
});

0 commit comments

Comments
 (0)