Skip to content

Commit 1ec3d88

Browse files
cynarlabclaude
andcommitted
fix(javascript): vendor the pinned js-debug release without a GitHub API call (#867, #813)
build-js-debug.js asked api.github.com for the release's asset list before downloading, and that lookup is subject to the unauthenticated REST quota (60/h per IP). Shared CI runners and `docker build` (which carries no token) run out of it: the COBOL Host job's postinstall vendoring and the daily canary's image build both failed with 403. vendor-manifest.json pins the tag AND the asset name, so the pinned build now downloads straight from <upstream>/releases/download/<tag>/<asset> — served by github.com, outside the API quota, the way vendor-codelldb.js already does. 'latest' and version overrides still go through the API, and a direct download that fails (an upstream re-release under another name) falls back to it. The cobol-host job's install step also carries GITHUB_TOKEN, like the other jobs' vendor step. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 55fbfea commit 1ec3d88

6 files changed

Lines changed: 152 additions & 14 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,12 @@ jobs:
406406
head -1 /tmp/cobc-version.txt | grep -E '^cobc \(GnuCOBOL\) 3\.1\.2([. ]|$)'
407407
- name: Install dependencies
408408
run: pnpm install --frozen-lockfile
409+
# This job vendors in postinstall (it needs CodeLLDB and js-debug for
410+
# the smoke); the pinned js-debug asset downloads without the API, but
411+
# the token keeps a 'latest'/override build off the unauthenticated
412+
# quota like the other jobs' vendor step (issue #813).
413+
env:
414+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
409415
- name: Build project
410416
run: pnpm build
411417
- name: Run COBOL host smoke and logpoints

‎changelog.d/867.fixed.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
**Installs and Docker builds no longer fail on the GitHub API rate limit** — vendoring the pinned js-debug release asked `api.github.com` for the release's asset list before downloading, and that lookup is subject to the unauthenticated quota of 60 requests per hour per IP: on a shared CI runner or inside a `docker build` (which has no token) it answered 403, failing the COBOL Host job's `pnpm install` and the daily published-artifact canary's image build. The pin in `vendor-manifest.json` already names the tag and the asset, so the vendoring now downloads it straight from `releases/download/`, which is outside the API quota — no token is needed for a pinned build. `JS_DEBUG_VERSION=latest` and version overrides still resolve through the API, and a direct download that fails falls back to it (#867, #813)

‎packages/adapter-javascript/scripts/build-js-debug.js‎

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
/**
33
* Vendor Microsoft js-debug vsDebugServer.js into vendor/js-debug
44
*
5-
* - Fetches prebuilt artifact from GitHub releases (preferred)
5+
* - Fetches prebuilt artifact from GitHub releases (preferred); the pinned
6+
* release in vendor-manifest.json is downloaded directly from
7+
* releases/download (no GitHub API call, so no rate limit — issues #867/#813)
68
* - Optional build-from-source fallback when explicitly enabled
79
* - Cross-platform (Windows/macOS/Linux), Node 18+ (uses global fetch)
810
* - Deterministic output:
@@ -11,8 +13,9 @@
1113
* - vendor/js-debug/manifest.json
1214
*
1315
* Environment variables:
14-
* - JS_DEBUG_VERSION: tag or 'latest' (default: 'latest')
15-
* - GH_TOKEN: GitHub token to avoid API rate limits (optional)
16+
* - JS_DEBUG_VERSION: tag or 'latest' (default: the pin in vendor-manifest.json)
17+
* - GH_TOKEN: GitHub token to avoid API rate limits (optional; only the API
18+
* path — 'latest' or a version override — is subject to them)
1619
* - JS_DEBUG_FORCE_REBUILD: 'true' to ignore cache and refetch
1720
* - JS_DEBUG_BUILD_FROM_SOURCE: 'true' to build from source if prebuilt fetch fails or is desired
1821
*
@@ -31,7 +34,7 @@ import { spawn } from 'node:child_process';
3134
import { extract as tarExtract } from 'tar';
3235
import extractZip from 'extract-zip';
3336
import { ensureDir, copy as fsxCopy } from 'fs-extra';
34-
import { selectBestAsset, normalizePath } from './lib/js-debug-helpers.js';
37+
import { selectBestAsset, normalizePath, pinnedAssetCandidate } from './lib/js-debug-helpers.js';
3538
import { determineVendoringPlan } from './lib/vendor-strategy.js';
3639

3740
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -559,18 +562,42 @@ async function main() {
559562
try {
560563
// Attempt prebuilt path first
561564
tmpDir = await makeTmpDir('js-debug-dl-');
562-
logInfo(`Fetching GitHub release '${VERSION}' for ${REPO_OWNER}/${REPO_NAME} ...`);
563-
const release = await getRelease(VERSION);
564-
if (release?.tag_name) {
565-
resolvedVersion = release.tag_name;
565+
566+
// The pinned release's asset is downloaded straight from
567+
// github.com/<repo>/releases/download/, which is outside the GitHub REST
568+
// quota (60/h per IP unauthenticated — exhausted on shared CI runners and
569+
// inside Docker builds, issues #867/#813). The API is asked only when the
570+
// pin cannot name the asset, or when the direct download fails (an
571+
// upstream re-release under another name), so the behaviour for 'latest'
572+
// and version overrides is unchanged.
573+
let best = null;
574+
let archiveFile = null;
575+
const pinned = pinnedAssetCandidate(PIN, VERSION);
576+
if (pinned) {
577+
archiveFile = path.join(tmpDir, `asset.${pinned.type === 'tgz' ? 'tgz' : 'zip'}`);
578+
logInfo(`Pinned release ${VERSION}: downloading ${pinned.name} directly (no GitHub API call) ...`);
579+
try {
580+
await downloadWithRetries(pinned.url, archiveFile);
581+
best = pinned;
582+
} catch (err) {
583+
logWarn(`Direct download of the pinned asset failed: ${(err && err.message) || err}. Falling back to the GitHub release API.`);
584+
}
566585
}
567586

568-
const assets = Array.isArray(release?.assets) ? release.assets : [];
569-
const best = selectBestAsset(assets);
570-
const archiveFile = path.join(tmpDir, `asset.${best.type === 'tgz' ? 'tgz' : 'zip'}`);
587+
if (!best) {
588+
logInfo(`Fetching GitHub release '${VERSION}' for ${REPO_OWNER}/${REPO_NAME} ...`);
589+
const release = await getRelease(VERSION);
590+
if (release?.tag_name) {
591+
resolvedVersion = release.tag_name;
592+
}
593+
594+
const assets = Array.isArray(release?.assets) ? release.assets : [];
595+
best = selectBestAsset(assets);
596+
archiveFile = path.join(tmpDir, `asset.${best.type === 'tgz' ? 'tgz' : 'zip'}`);
571597

572-
logInfo(`Selected asset: ${best.name} (${best.type}). Downloading...`);
573-
await downloadWithRetries(best.url, archiveFile);
598+
logInfo(`Selected asset: ${best.name} (${best.type}). Downloading...`);
599+
await downloadWithRetries(best.url, archiveFile);
600+
}
574601

575602
// Supply-chain integrity gate: verify the downloaded archive against the
576603
// committed digest pin before extracting anything from it. GitHub release

‎packages/adapter-javascript/scripts/lib/js-debug-helpers.d.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,19 @@ export interface SelectedAsset {
3535
* @throws {Error} when no asset matches, naming the ones that were available
3636
*/
3737
export function selectBestAsset(assets: readonly ReleaseAsset[]): SelectedAsset;
38+
39+
/** The `js-debug` entry of `vendor-manifest.json`, as the pin reader sees it. */
40+
export interface JsDebugPin {
41+
version?: string;
42+
upstream?: string;
43+
/** asset file name → expected sha256 of the archive */
44+
assets?: Record<string, string>;
45+
}
46+
47+
/**
48+
* The asset a pinned vendoring downloads straight from
49+
* `<upstream>/releases/download/<tag>/<asset>` — outside the GitHub REST
50+
* quota (issues #867, #813). `null` when the API has to resolve the release
51+
* (`latest`, a version override, or a pin that names no archive).
52+
*/
53+
export function pinnedAssetCandidate(pin: JsDebugPin, version: string): SelectedAsset | null;

‎packages/adapter-javascript/scripts/lib/js-debug-helpers.js‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,41 @@ export function selectBestAsset(assets) {
101101

102102
return pick;
103103
}
104+
105+
/**
106+
* The asset a pinned vendoring can download without asking the GitHub API.
107+
*
108+
* `vendor-manifest.json` pins the release tag AND names the asset it expects,
109+
* so for the pinned version the download URL is already known:
110+
* `<upstream>/releases/download/<tag>/<asset>`. That URL is served by
111+
* github.com (a redirect to the release CDN), not by api.github.com, so it
112+
* is outside the unauthenticated REST quota of 60 requests per hour per IP —
113+
* the one a shared CI runner or a Docker build has already used up when the
114+
* `releases/tags/<tag>` lookup answers 403 (issues #867, #813). The API is
115+
* only needed to resolve `latest` or a version override away from the pin.
116+
*
117+
* @param {{ version?: string, upstream?: string, assets?: Record<string, string> }} pin
118+
* The `js-debug` entry of vendor-manifest.json
119+
* @param {string} version The requested version (`JS_DEBUG_VERSION` or the pin)
120+
* @returns {{ url: string, name: string, type: 'tgz' | 'zip' } | null}
121+
* The asset to download directly, or null when the API has to resolve it
122+
*/
123+
export function pinnedAssetCandidate(pin, version) {
124+
const tag = pin?.version;
125+
const upstream = String(pin?.upstream || '').replace(/\/+$/, '');
126+
const names = Object.keys(pin?.assets || {});
127+
if (!tag || !upstream || version !== tag || names.length === 0) {
128+
return null;
129+
}
130+
const assets = names.map(name => ({
131+
name,
132+
browser_download_url: `${upstream}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`
133+
}));
134+
try {
135+
return selectBestAsset(assets);
136+
} catch {
137+
// None of the pinned names is an archive the vendoring understands —
138+
// let the API path report what the release actually offers.
139+
return null;
140+
}
141+
}

‎packages/adapter-javascript/tests/unit/build-js-debug.helpers.test.ts‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect } from 'vitest';
22

33
// Import ESM helper from JS file
4-
import { selectBestAsset, normalizePath } from '../../scripts/lib/js-debug-helpers';
4+
import { selectBestAsset, normalizePath, pinnedAssetCandidate } from '../../scripts/lib/js-debug-helpers';
55

66
describe('js-debug helpers: normalizePath', () => {
77
it('normalizes backslashes to forward slashes', () => {
@@ -85,3 +85,53 @@ describe('js-debug helpers: selectBestAsset', () => {
8585
expect(() => selectBestAsset(assets)).toThrow(/No matching js-debug asset found/i);
8686
});
8787
});
88+
89+
describe('js-debug helpers: pinnedAssetCandidate (issues #867, #813)', () => {
90+
const pin = {
91+
version: 'v1.112.0',
92+
upstream: 'https://github.com/microsoft/vscode-js-debug',
93+
assets: {
94+
'js-debug-dap-v1.112.0.tar.gz': '31eb1bd9792f62c32f7c22b66ce612e2e54a7664201a2d80bdb49cc4bf4ca925'
95+
}
96+
};
97+
98+
it('builds a releases/download URL for the pinned asset so no GitHub API call is needed', () => {
99+
const candidate = pinnedAssetCandidate(pin, 'v1.112.0');
100+
expect(candidate).toEqual({
101+
name: 'js-debug-dap-v1.112.0.tar.gz',
102+
url: 'https://github.com/microsoft/vscode-js-debug/releases/download/v1.112.0/js-debug-dap-v1.112.0.tar.gz',
103+
type: 'tgz'
104+
});
105+
});
106+
107+
it('returns null for "latest" — the release must be resolved through the API', () => {
108+
expect(pinnedAssetCandidate(pin, 'latest')).toBeNull();
109+
});
110+
111+
it('returns null for a version override away from the pin', () => {
112+
expect(pinnedAssetCandidate(pin, 'v1.111.0')).toBeNull();
113+
});
114+
115+
it('returns null when the pin names no assets', () => {
116+
expect(pinnedAssetCandidate({ ...pin, assets: {} }, 'v1.112.0')).toBeNull();
117+
expect(pinnedAssetCandidate({ version: 'v1.112.0', upstream: pin.upstream }, 'v1.112.0')).toBeNull();
118+
});
119+
120+
it('applies the selectBestAsset preference when the pin names several assets', () => {
121+
const several = {
122+
...pin,
123+
assets: {
124+
'js-debug-dap-v1.112.0.zip': 'a',
125+
'js-debug-dap-v1.112.0.tar.gz': 'b'
126+
}
127+
};
128+
expect(pinnedAssetCandidate(several, 'v1.112.0')?.name).toBe('js-debug-dap-v1.112.0.tar.gz');
129+
});
130+
131+
it('derives the download host from the pin upstream, with a trailing slash tolerated', () => {
132+
const forked = { ...pin, upstream: 'https://github.com/example/js-debug-fork/' };
133+
expect(pinnedAssetCandidate(forked, 'v1.112.0')?.url).toBe(
134+
'https://github.com/example/js-debug-fork/releases/download/v1.112.0/js-debug-dap-v1.112.0.tar.gz'
135+
);
136+
});
137+
});

0 commit comments

Comments
 (0)