Skip to content

Commit de2365c

Browse files
fix: resolve cumulative update links from Microsoft download pages
Parse Microsoft download metadata without running scripts and fall back to legacy links when metadata is unusable. Accept only HTTPS Microsoft EXE URLs and retain metadata, page and network failure diagnostics. Retry all non-2xx download-page responses, network errors and timeouts up to three times with 5s/10s delays. Log retries at info level and warn then install without updates if resolution or download ultimately fails. Refs tediousjs/tedious#1807 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 8f5ac33 commit de2365c

8 files changed

Lines changed: 343 additions & 50 deletions

File tree

‎.github/copilot-instructions.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ The build step (`npm run build`) also runs `npm run docs`, which regenerates the
4646
2. Validates OS compatibility using version config from `src/versions.ts`
4747
3. Optionally installs SQL Native Client (`src/install-native-client.ts`) and ODBC driver (`src/install-odbc.ts`)
4848
4. Downloads or cache-hits the SQL Server installer (box+exe, standalone exe, or SSEI bootstrapper)
49-
5. Optionally downloads cumulative updates
49+
5. Optionally downloads cumulative updates (resolves Microsoft download-page JSON, falls back to legacy links if metadata is unusable, retries network errors, timeouts and all non-2xx page responses up to three times with 5- and 10-second delays, and warns then installs without updates if the download fails)
5050
6. Runs the installer via `@actions/exec`
5151
7. Waits for the database to be ready (exponential backoff)
5252

‎README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ See [action.yml](./action.yml):
4747
```
4848
<!-- end usage -->
4949
50+
### Cumulative updates
51+
52+
When `install-updates: true` is set for a version with a configured update URL,
53+
the action downloads the update before starting SQL Server setup. Download-page
54+
requests are tried up to three times on network errors, timeouts, or non-2xx
55+
responses (including 403 and 404), with 5- and 10-second delays before retries.
56+
If the update still can't be downloaded, the action logs a warning with the
57+
reason and installs SQL Server without updates. Versions without a configured
58+
update URL skip updates.
59+
5060
### Basic usage
5161

5262
```yml

‎lib/main/index.js‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎lib/main/index.js.map‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/install.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,15 @@ export default async function install() {
103103
if (!config.updateUrl) {
104104
core.info('Skipping update installation - version not supported');
105105
} else {
106-
const updatePath = await core.group(`Fetching cumulative updates for ${version}`, () => findOrDownloadUpdates(config));
106+
const updatePath = await core.group(`Fetching cumulative updates for ${version}`, async () => {
107+
try {
108+
return await findOrDownloadUpdates(config);
109+
} catch (error) {
110+
const reason = error instanceof Error ? error.message : String(error);
111+
core.warning(`Unable to download cumulative updates; installing without updates. ${reason}`);
112+
return '';
113+
}
114+
});
107115
if (updatePath) {
108116
installArgs.push('/UPDATEENABLED=1', `/UpdateSource=${dirname(updatePath)}`);
109117
}

‎src/utils.ts‎

Lines changed: 99 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { basename, extname, dirname, join as joinPaths } from 'node:path';
22
import { readdir } from 'node:fs/promises';
3+
import { setTimeout as delay } from 'node:timers/promises';
34
import * as core from '@actions/core';
45
import * as exec from '@actions/exec';
56
import * as glob from '@actions/glob';
@@ -226,8 +227,100 @@ export async function downloadExeInstaller(config: VersionConfig): Promise<strin
226227
return joinPaths(toolPath, 'setup.exe');
227228
}
228229

230+
function isUpdateDownloadUrl(value: unknown): value is string {
231+
return typeof value === 'string' && /^https:\/\/download\.microsoft\.com\/[^\s"'<>?#]+\.exe$/i.test(value);
232+
}
233+
234+
function isRecord(value: unknown): value is Record<string, unknown> {
235+
return typeof value === 'object' && value !== null && !Array.isArray(value);
236+
}
237+
238+
function extractUpdateDownloadUrl(body: string): string {
239+
const links = new Set<string>();
240+
const metadataProblems: string[] = [];
241+
for (const [, script] of body.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi)) {
242+
const assignment = script.match(/^\s*window\.__DLCDetails__\s*=\s*([\s\S]*?)\s*;?\s*$/);
243+
if (!assignment) {
244+
continue;
245+
}
246+
try {
247+
const details: unknown = JSON.parse(assignment[1]);
248+
if (!isRecord(details) || !isRecord(details.dlcDetailsView)) {
249+
throw new Error('Invalid cumulative update metadata in Microsoft download page');
250+
}
251+
const { downloadFile: files, error: pageError } = details.dlcDetailsView;
252+
if (typeof pageError === 'string' && pageError.trim()) {
253+
const reason = `Microsoft download page error: ${pageError}`;
254+
metadataProblems.push(reason);
255+
core.debug(reason);
256+
}
257+
if (!Array.isArray(files)) {
258+
throw new Error('Invalid cumulative update file list in Microsoft download page');
259+
}
260+
for (const file of files) {
261+
if (isRecord(file) && isUpdateDownloadUrl(file.url)) {
262+
links.add(file.url);
263+
}
264+
}
265+
} catch (error) {
266+
const reason = error instanceof Error ? error.message : String(error);
267+
metadataProblems.push(reason);
268+
core.debug(`Unable to use cumulative update metadata: ${reason}`);
269+
}
270+
}
271+
if (!links.size) {
272+
for (const [, link] of body.matchAll(/<a\b[^>]*?\s+href\s*=\s*["']([^"']+)["'][^>]*>/gi)) {
273+
if (isUpdateDownloadUrl(link)) {
274+
links.add(link);
275+
}
276+
}
277+
}
278+
if (links.size !== 1) {
279+
core.debug(body);
280+
const reason = links.size
281+
? 'Multiple cumulative update installers found in Microsoft download page'
282+
: 'No HTTPS download.microsoft.com .exe cumulative update installer found in Microsoft download page';
283+
throw new Error(`${reason}${metadataProblems.length ? `. Metadata problems: ${metadataProblems.join('; ')}` : ''}`);
284+
}
285+
const [link] = links;
286+
return link;
287+
}
288+
289+
async function fetchUpdatePage(url: string): Promise<string> {
290+
const attempts = 3;
291+
for (let attempt = 1; ; attempt++) {
292+
let retryable = false;
293+
try {
294+
const res = await fetch(url, { signal: AbortSignal.timeout(30_000) });
295+
if (!res.ok) {
296+
retryable = true;
297+
throw new Error(`HTTP ${res.status}`);
298+
}
299+
return await res.text();
300+
} catch (error) {
301+
retryable ||= error instanceof TypeError || (error instanceof Error && error.name === 'TimeoutError');
302+
let reason = error instanceof Error ? error.message : String(error);
303+
if (error instanceof Error && error.cause) {
304+
const cause = error.cause;
305+
const causeDetails = isRecord(cause)
306+
? [cause.message, cause.code].filter((value): value is string => typeof value === 'string' && value.length > 0).join(' / ')
307+
: typeof cause === 'string' ? cause : '';
308+
if (causeDetails) {
309+
reason += ` (${causeDetails})`;
310+
}
311+
}
312+
if (!retryable || attempt === attempts) {
313+
throw new Error(`Unable to fetch cumulative update page ${url} after ${attempt} attempt(s): ${reason}`, { cause: error });
314+
}
315+
core.info(`Cumulative update page fetch failed (${reason}); retrying (${attempt + 1}/${attempts})`);
316+
await delay(5000 * attempt);
317+
}
318+
}
319+
}
320+
229321
/**
230-
* Downloads cumulative updates for supported versions.
322+
* Downloads cumulative updates for supported versions. Throws with the failure
323+
* reason if a configured update cannot be fetched or resolved.
231324
*
232325
* @param {VersionConfig} config
233326
* @returns {Promise<string>}
@@ -236,28 +329,11 @@ export async function downloadUpdateInstaller(config: VersionConfig): Promise<st
236329
if (!config.updateUrl) {
237330
throw new Error('No update url provided');
238331
}
239-
// resolve download url
240-
let downloadLink: string | null = null;
241-
if (!config.updateUrl.endsWith('.exe')) {
242-
const res = await fetch(config.updateUrl);
243-
if (res.ok) {
244-
const body = await res.text();
245-
const [, link] = body.match(/\s+href\s*=\s*["'](https:\/\/download\.microsoft\.com\/.*\.exe)['"]/) ?? [];
246-
if (link) {
247-
downloadLink = link;
248-
} else {
249-
core.info('Unable to find download link in body');
250-
core.debug(body);
251-
}
252-
}
253-
if (!downloadLink) {
254-
core.warning('Unable to download cumulative updates');
255-
core.info(`Response code: ${res.status}`);
256-
return '';
257-
}
258-
}
259-
core.info(`Downloading cumulative update from ${downloadLink ?? config.updateUrl}`);
260-
const updatePath = await downloadTool(downloadLink ?? config.updateUrl);
332+
const downloadLink = config.updateUrl.endsWith('.exe')
333+
? config.updateUrl
334+
: extractUpdateDownloadUrl(await fetchUpdatePage(config.updateUrl));
335+
core.info(`Downloading cumulative update from ${downloadLink}`);
336+
const updatePath = await downloadTool(downloadLink);
261337
if (core.isDebug()) {
262338
const hash = await generateFileHash(updatePath);
263339
core.debug(`Got update file with hash SHA256=${hash.toString('base64')}`);

‎test/install.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,20 @@ describe('install', () => {
173173
assert.ok(args.includes('/UPDATEENABLED=1'));
174174
assert.ok(args.includes('/UpdateSource=C:/tool-cache/sql-update'));
175175
});
176+
for (const error of [new Error('Unable to fetch cumulative update page: HTTP 403'), 'Unable to fetch cumulative update page: HTTP 403']) {
177+
it(`installs without updates and warns if requested updates fail with ${typeof error}`, async () => {
178+
utils.gatherInputs.mock.mockImplementation(() => defaultInputs({ installUpdates: true }));
179+
utils.downloadUpdateInstaller.mock.mockImplementation(async () => {
180+
throw error;
181+
});
182+
await install();
183+
const args = exec.exec.mock.calls[0].arguments[1] as string[];
184+
assert.ok(!args.includes('/UPDATEENABLED=1'));
185+
assert.ok(!args.some((arg) => arg.startsWith('/UpdateSource=')));
186+
assert.equal(core.warning.mock.callCount(), 1);
187+
assert.equal(core.warning.mock.calls[0].arguments[0], 'Unable to download cumulative updates; installing without updates. Unable to fetch cumulative update page: HTTP 403');
188+
});
189+
}
176190
it('skips cumulative updates if no update url', async () => {
177191
utils.gatherInputs.mock.mockImplementation(() => defaultInputs({ version: 'minOs', installUpdates: true }));
178192
await install();

0 commit comments

Comments
 (0)