Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ The build step (`npm run build`) also runs `npm run docs`, which regenerates the
2. Validates OS compatibility using version config from `src/versions.ts`
3. Optionally installs SQL Native Client (`src/install-native-client.ts`) and ODBC driver (`src/install-odbc.ts`)
4. Downloads or cache-hits the SQL Server installer (box+exe, standalone exe, or SSEI bootstrapper)
5. Optionally downloads cumulative updates
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)
6. Runs the installer via `@actions/exec`
7. Waits for the database to be ready (exponential backoff)

Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ See [action.yml](./action.yml):
```
<!-- end usage -->

### Cumulative updates

When `install-updates: true` is set for a version with a configured update URL,
the action downloads the update before starting SQL Server setup. Download-page
requests are tried up to three times on network errors, timeouts, or non-2xx
responses (including 403 and 404), with 5- and 10-second delays before retries.
If the update still can't be downloaded, the action logs a warning with the
reason and installs SQL Server without updates. Versions without a configured
update URL skip updates.

### Basic usage

```yml
Expand Down
6 changes: 3 additions & 3 deletions lib/main/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion lib/main/index.js.map

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion src/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,15 @@ export default async function install() {
if (!config.updateUrl) {
core.info('Skipping update installation - version not supported');
} else {
const updatePath = await core.group(`Fetching cumulative updates for ${version}`, () => findOrDownloadUpdates(config));
const updatePath = await core.group(`Fetching cumulative updates for ${version}`, async () => {
try {
return await findOrDownloadUpdates(config);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
core.warning(`Unable to download cumulative updates; installing without updates. ${reason}`);
return '';
}
});
if (updatePath) {
installArgs.push('/UPDATEENABLED=1', `/UpdateSource=${dirname(updatePath)}`);
}
Expand Down
122 changes: 99 additions & 23 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { basename, extname, dirname, join as joinPaths } from 'node:path';
import { readdir } from 'node:fs/promises';
import { setTimeout as delay } from 'node:timers/promises';
import * as core from '@actions/core';
import * as exec from '@actions/exec';
import * as glob from '@actions/glob';
Expand Down Expand Up @@ -226,8 +227,100 @@ export async function downloadExeInstaller(config: VersionConfig): Promise<strin
return joinPaths(toolPath, 'setup.exe');
}

function isUpdateDownloadUrl(value: unknown): value is string {
return typeof value === 'string' && /^https:\/\/download\.microsoft\.com\/[^\s"'<>?#]+\.exe$/i.test(value);
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function extractUpdateDownloadUrl(body: string): string {
const links = new Set<string>();
const metadataProblems: string[] = [];
for (const [, script] of body.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi)) {
const assignment = script.match(/^\s*window\.__DLCDetails__\s*=\s*([\s\S]*?)\s*;?\s*$/);
if (!assignment) {
continue;
}
try {
const details: unknown = JSON.parse(assignment[1]);
if (!isRecord(details) || !isRecord(details.dlcDetailsView)) {
throw new Error('Invalid cumulative update metadata in Microsoft download page');
}
const { downloadFile: files, error: pageError } = details.dlcDetailsView;
if (typeof pageError === 'string' && pageError.trim()) {
const reason = `Microsoft download page error: ${pageError}`;
metadataProblems.push(reason);
core.debug(reason);
}
if (!Array.isArray(files)) {
throw new Error('Invalid cumulative update file list in Microsoft download page');
}
for (const file of files) {
if (isRecord(file) && isUpdateDownloadUrl(file.url)) {
links.add(file.url);
}
}
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
metadataProblems.push(reason);
core.debug(`Unable to use cumulative update metadata: ${reason}`);
}
}
if (!links.size) {
for (const [, link] of body.matchAll(/<a\b[^>]*?\s+href\s*=\s*["']([^"']+)["'][^>]*>/gi)) {
if (isUpdateDownloadUrl(link)) {
links.add(link);
}
}
}
if (links.size !== 1) {
core.debug(body);
const reason = links.size
? 'Multiple cumulative update installers found in Microsoft download page'
: 'No HTTPS download.microsoft.com .exe cumulative update installer found in Microsoft download page';
throw new Error(`${reason}${metadataProblems.length ? `. Metadata problems: ${metadataProblems.join('; ')}` : ''}`);
}
Comment thread
cheenamalhotra marked this conversation as resolved.
const [link] = links;
return link;
}

async function fetchUpdatePage(url: string): Promise<string> {
const attempts = 3;
for (let attempt = 1; ; attempt++) {
let retryable = false;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(30_000) });
if (!res.ok) {
retryable = true;
throw new Error(`HTTP ${res.status}`);
}
return await res.text();
} catch (error) {
retryable ||= error instanceof TypeError || (error instanceof Error && error.name === 'TimeoutError');
let reason = error instanceof Error ? error.message : String(error);
if (error instanceof Error && error.cause) {
const cause = error.cause;
const causeDetails = isRecord(cause)
? [cause.message, cause.code].filter((value): value is string => typeof value === 'string' && value.length > 0).join(' / ')
: typeof cause === 'string' ? cause : '';
if (causeDetails) {
reason += ` (${causeDetails})`;
}
}
if (!retryable || attempt === attempts) {
throw new Error(`Unable to fetch cumulative update page ${url} after ${attempt} attempt(s): ${reason}`, { cause: error });
}
core.info(`Cumulative update page fetch failed (${reason}); retrying (${attempt + 1}/${attempts})`);
await delay(5000 * attempt);
}
}
}

/**
* Downloads cumulative updates for supported versions.
* Downloads cumulative updates for supported versions. Throws with the failure
* reason if a configured update cannot be fetched or resolved.
*
* @param {VersionConfig} config
* @returns {Promise<string>}
Expand All @@ -236,28 +329,11 @@ export async function downloadUpdateInstaller(config: VersionConfig): Promise<st
if (!config.updateUrl) {
throw new Error('No update url provided');
}
// resolve download url
let downloadLink: string | null = null;
if (!config.updateUrl.endsWith('.exe')) {
const res = await fetch(config.updateUrl);
if (res.ok) {
const body = await res.text();
const [, link] = body.match(/\s+href\s*=\s*["'](https:\/\/download\.microsoft\.com\/.*\.exe)['"]/) ?? [];
if (link) {
downloadLink = link;
} else {
core.info('Unable to find download link in body');
core.debug(body);
}
}
if (!downloadLink) {
core.warning('Unable to download cumulative updates');
core.info(`Response code: ${res.status}`);
return '';
}
}
core.info(`Downloading cumulative update from ${downloadLink ?? config.updateUrl}`);
const updatePath = await downloadTool(downloadLink ?? config.updateUrl);
const downloadLink = config.updateUrl.endsWith('.exe')
? config.updateUrl
: extractUpdateDownloadUrl(await fetchUpdatePage(config.updateUrl));
core.info(`Downloading cumulative update from ${downloadLink}`);
const updatePath = await downloadTool(downloadLink);
if (core.isDebug()) {
const hash = await generateFileHash(updatePath);
core.debug(`Got update file with hash SHA256=${hash.toString('base64')}`);
Expand Down
14 changes: 14 additions & 0 deletions test/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,20 @@ describe('install', () => {
assert.ok(args.includes('/UPDATEENABLED=1'));
assert.ok(args.includes('/UpdateSource=C:/tool-cache/sql-update'));
});
for (const error of [new Error('Unable to fetch cumulative update page: HTTP 403'), 'Unable to fetch cumulative update page: HTTP 403']) {
it(`installs without updates and warns if requested updates fail with ${typeof error}`, async () => {
utils.gatherInputs.mock.mockImplementation(() => defaultInputs({ installUpdates: true }));
utils.downloadUpdateInstaller.mock.mockImplementation(async () => {
throw error;
});
await install();
const args = exec.exec.mock.calls[0].arguments[1] as string[];
assert.ok(!args.includes('/UPDATEENABLED=1'));
assert.ok(!args.some((arg) => arg.startsWith('/UpdateSource=')));
assert.equal(core.warning.mock.callCount(), 1);
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');
});
}
it('skips cumulative updates if no update url', async () => {
utils.gatherInputs.mock.mockImplementation(() => defaultInputs({ version: 'minOs', installUpdates: true }));
await install();
Expand Down
Loading