Skip to content

Commit 48fd46e

Browse files
authored
Merge pull request #299 from dhensby/fix/review-follow-ups
fix: clearer installer and cumulative update diagnostics
2 parents b3703a8 + 1683d33 commit 48fd46e

9 files changed

Lines changed: 105 additions & 35 deletions

File tree

‎.github/copilot-instructions.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,13 @@ 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 (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)
49+
5. Optionally downloads cumulative updates (resolves Microsoft download-page JSON, falls back to legacy `<a href>` links if the metadata is missing or unusable, fetches the page up to three times, retrying network errors, timeouts and non-2xx responses after 5 and then 10 seconds, and if resolving or downloading the update fails, warns and installs without updates)
5050
6. Runs the installer via `@actions/exec`
5151
7. Waits for the database to be ready (exponential backoff)
5252

5353
**Installer abstraction:** `src/installers/` contains a base `Installer` class and `MsiInstaller` subclass used by the native client and ODBC installations. SQL Server itself uses direct exe/box download logic or the SSEI bootstrapper (for 2025+) in `src/utils.ts`.
5454

55-
**Version registry:** `src/versions.ts` defines a `Map<string, VersionConfig>` with download URLs (exe/box or SSEI), optional update URLs, and OS compatibility constraints for each supported SQL Server version (2008–2025). SQL Server 2025+ uses the SSEI bootstrapper model (`sseiUrl`) instead of direct exe/box downloads. For SSEI versions, `downloadSseiInstaller()` downloads the media into an isolated temporary directory before extracting and caching the installer. Box and SSEI installers are both extracted into their own temporary directory before caching.
55+
**Version registry:** `src/versions.ts` defines a `Map<string, VersionConfig>` with download URLs (exe/box or SSEI), optional update URLs, and OS compatibility constraints for each supported SQL Server version (2008–2025). SQL Server 2025+ uses the SSEI bootstrapper model (`sseiUrl`) instead of direct exe/box downloads. For SSEI versions, `downloadSseiInstaller()` first downloads the media into its own temporary directory. Box and SSEI media are then extracted into a fresh temporary directory by `extractAndCacheInstaller()`, which checks that `setup.exe` was produced before adding it to the tool cache.
5656

5757
**Build output:** Rollup (config in `rollup.config.mjs`) bundles everything into `lib/main/index.js` as a minified ESM module, which is what `action.yml` references. The `lib/` directory is committed to the repository. **Every commit must include up-to-date build output** — CI checks this by rebuilding and running `git diff-files --quiet`. Always run `npm run build` and commit the resulting changes to `lib/` and `README.md` before pushing.
5858

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ See [action.yml](./action.yml):
1414
skip-os-check: false
1515

1616
# SQL Server version: 2008, 2012, 2014, 2016, 2017, 2019, 2022, or 2025. "latest"
17-
# selects 2025.
17+
# selects 2025. 2008, 2012 and 2014 only pass the OS check on Windows Server 2019
18+
# or older, which GitHub-hosted runners no longer offer; set skip-os-check to
19+
# attempt them on newer runners.
1820
# Default: latest
1921
sqlserver-version: 'latest'
2022

‎action.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ inputs:
66
description: 'Skip OS checks that will stop installation attempts preemptively.'
77
default: 'false'
88
sqlserver-version:
9-
description: 'SQL Server version: 2008, 2012, 2014, 2016, 2017, 2019, 2022, or 2025. "latest" selects 2025.'
9+
description: 'SQL Server version: 2008, 2012, 2014, 2016, 2017, 2019, 2022, or 2025. "latest" selects 2025. 2008, 2012 and 2014 only pass the OS check on Windows Server 2019 or older, which GitHub-hosted runners no longer offer; set skip-os-check to attempt them on newer runners.'
1010
default: 'latest'
1111
native-client-version:
1212
description: 'Version of native client to install. Only 11 is supported.'

‎lib/main/index.js‎

Lines changed: 1 addition & 1 deletion
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export default async function install() {
8888
message += `windows-${max}`;
8989
}
9090
message += '.';
91-
throw new Error(`Runner version windows-${osVersion} is not supported for SQL Server ${version}. ${message}`);
91+
throw new Error(`Runner version windows-${osVersion} is not supported for SQL Server ${version}. ${message} Alternatively, set skip-os-check to attempt the installation anyway.`);
9292
}
9393
}
9494
if (nativeClientVersion) {

‎src/utils.ts‎

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,11 @@ async function extractAndCacheInstaller(exePath: string, version: string): Promi
159159
cwd: workDir,
160160
windowsVerbatimArguments: true,
161161
});
162+
// don't cache an extraction that produced no setup.exe
163+
const extracted = await readdir(setupDir);
164+
if (!extracted.some((file) => file.toLowerCase() === 'setup.exe')) {
165+
throw new Error(`Extracting the SQL Server ${version} installer did not produce setup.exe in ${setupDir} (found: ${extracted.join(', ') || 'no files'})`);
166+
}
162167
core.info('Adding to the cache');
163168
const toolPath = await tc.cacheDir(setupDir, 'sqlserver', version);
164169
core.debug(`Cached @ ${toolPath}`);
@@ -198,7 +203,7 @@ export async function downloadSseiInstaller(config: VersionConfig): Promise<stri
198203
const files = await readdir(mediaDir);
199204
const exeFiles = files.filter((file) => file.toLowerCase().endsWith('.exe'));
200205
if (exeFiles.length === 0) {
201-
throw new Error('SSEI bootstrapper did not produce an installer exe');
206+
throw new Error(`SSEI bootstrapper did not produce an installer exe in ${mediaDir} (found: ${files.join(', ') || 'no files'})`);
202207
}
203208
if (exeFiles.length > 1) {
204209
throw new Error(`SSEI bootstrapper produced multiple installer exes: ${exeFiles.join(', ')}`);
@@ -260,10 +265,14 @@ function extractUpdateDownloadUrl(body: string): string {
260265
if (!Array.isArray(files)) {
261266
throw new Error('Invalid cumulative update file list in Microsoft download page');
262267
}
263-
for (const file of files) {
264-
if (isRecord(file) && isUpdateDownloadUrl(file.url)) {
265-
links.add(file.url);
266-
}
268+
const urls = files.filter(isRecord).map((file) => file.url);
269+
const installerUrls = urls.filter(isUpdateDownloadUrl);
270+
if (!installerUrls.length) {
271+
const listed = [...new Set(urls.filter((url): url is string => typeof url === 'string' && url.length > 0))];
272+
throw new Error(`File list has no HTTPS download.microsoft.com .exe installer${listed.length ? ` (found: ${listed.join(', ')})` : ''}`);
273+
}
274+
for (const url of installerUrls) {
275+
links.add(url);
267276
}
268277
} catch (error) {
269278
const reason = error instanceof Error ? error.message : String(error);
@@ -289,6 +298,25 @@ function extractUpdateDownloadUrl(body: string): string {
289298
return link;
290299
}
291300

301+
/**
302+
* Summarise a fetch failure's cause, leaving out the error code when the
303+
* message already contains it (e.g. "getaddrinfo ENOTFOUND host").
304+
*
305+
* @param {unknown} cause
306+
* @returns {string}
307+
*/
308+
function describeCause(cause: unknown): string {
309+
if (typeof cause === 'string') {
310+
return cause;
311+
}
312+
if (!isRecord(cause)) {
313+
return '';
314+
}
315+
const message = typeof cause.message === 'string' ? cause.message : '';
316+
const code = typeof cause.code === 'string' && !message.includes(cause.code) ? cause.code : '';
317+
return [message, code].filter(Boolean).join(' / ');
318+
}
319+
292320
async function fetchUpdatePage(url: string): Promise<string> {
293321
const attempts = 3;
294322
for (let attempt = 1; ; attempt++) {
@@ -303,14 +331,9 @@ async function fetchUpdatePage(url: string): Promise<string> {
303331
} catch (error) {
304332
retryable ||= error instanceof TypeError || (error instanceof Error && error.name === 'TimeoutError');
305333
let reason = error instanceof Error ? error.message : String(error);
306-
if (error instanceof Error && error.cause) {
307-
const cause = error.cause;
308-
const causeDetails = isRecord(cause)
309-
? [cause.message, cause.code].filter((value): value is string => typeof value === 'string' && value.length > 0).join(' / ')
310-
: typeof cause === 'string' ? cause : '';
311-
if (causeDetails) {
312-
reason += ` (${causeDetails})`;
313-
}
334+
const causeDetails = error instanceof Error ? describeCause(error.cause) : '';
335+
if (causeDetails) {
336+
reason += ` (${causeDetails})`;
314337
}
315338
if (!retryable || attempt === attempts) {
316339
throw new Error(`Unable to fetch cumulative update page ${url} after ${attempt} attempt(s): ${reason}`, { cause: error });

‎test/install.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,21 +209,21 @@ describe('install', () => {
209209
it('errors on os max support', async () => {
210210
utils.gatherInputs.mock.mockImplementation(() => defaultInputs({ version: 'maxOs' }));
211211
await assert.rejects(() => install(), {
212-
message: 'Runner version windows-2022 is not supported for SQL Server maxOs. Please use windows-2019.',
212+
message: 'Runner version windows-2022 is not supported for SQL Server maxOs. Please use windows-2019. Alternatively, set skip-os-check to attempt the installation anyway.',
213213
});
214214
});
215215
it('errors on os min support', async () => {
216216
utils.getOsVersion.mock.mockImplementation(async () => 2019);
217217
utils.gatherInputs.mock.mockImplementation(() => defaultInputs({ version: 'minOs' }));
218218
await assert.rejects(() => install(), {
219-
message: 'Runner version windows-2019 is not supported for SQL Server minOs. Please use windows-2022.',
219+
message: 'Runner version windows-2019 is not supported for SQL Server minOs. Please use windows-2022. Alternatively, set skip-os-check to attempt the installation anyway.',
220220
});
221221
});
222222
it('errors on os min & max support', async () => {
223223
utils.getOsVersion.mock.mockImplementation(async () => 2016);
224224
utils.gatherInputs.mock.mockImplementation(() => defaultInputs({ version: 'minMaxOs' }));
225225
await assert.rejects(() => install(), {
226-
message: 'Runner version windows-2016 is not supported for SQL Server minMaxOs. Please use windows-2019 to windows-2022.',
226+
message: 'Runner version windows-2016 is not supported for SQL Server minMaxOs. Please use windows-2019 to windows-2022. Alternatively, set skip-os-check to attempt the installation anyway.',
227227
});
228228
});
229229
it('continues if no os version found', async () => {

‎test/utils.ts‎

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ const fetchMock = mock.fn(async () => fetchResponse);
3636
globalThis.fetch = fetchMock as unknown as typeof fetch;
3737

3838
const mkdtemp = mock.fn(async (prefix: string) => `${prefix}unique`);
39-
const readdir = mock.fn(async () => [] as string[]);
39+
// installer extraction expects setup.exe in its sqlserver-setup-* directory; `files` are listed everywhere else
40+
function mediaFiles(files: string[]) {
41+
return async (path: string) => (path.includes('sqlserver-setup-') ? ['setup.exe'] : files);
42+
}
43+
const readdir = mock.fn(mediaFiles([]));
4044
const generateFileHash = mock.fn(async () => randomBytes(32));
4145
const delay = mock.fn(async (milliseconds: number) => { assert.ok(milliseconds > 0); });
4246

@@ -77,7 +81,7 @@ function resetAll() {
7781
fetchResponse.text.mock.mockImplementation(async () => '');
7882
fetchMock.mock.mockImplementation(async () => fetchResponse);
7983
mkdtemp.mock.mockImplementation(async (prefix: string) => `${prefix}unique`);
80-
readdir.mock.mockImplementation(async () => []);
84+
readdir.mock.mockImplementation(mediaFiles([]));
8185
generateFileHash.mock.mockImplementation(async () => randomBytes(32));
8286
}
8387

@@ -250,10 +254,31 @@ describe('utils', () => {
250254
['/qs', '/x:"C:/runner temp/sqlserver-setup-unique"'],
251255
{ cwd: 'C:/runner temp', windowsVerbatimArguments: true },
252256
]);
257+
assert.deepEqual(readdir.mock.calls[0].arguments, ['C:/runner temp/sqlserver-setup-unique']);
253258
assert.deepEqual(tc.cacheDir.mock.calls[0].arguments, [
254259
'C:/runner temp/sqlserver-setup-unique', 'sqlserver', '2022',
255260
]);
256261
});
262+
it('fails without caching if extraction does not produce setup.exe', async () => {
263+
tc.downloadTool.mock.mockImplementation(async () => 'C:/runner temp/installer');
264+
readdir.mock.mockImplementation(async () => ['readme.txt']);
265+
await assert.rejects(() => utils.downloadBoxInstaller({
266+
exeUrl: 'https://example.com/installer.exe',
267+
boxUrl: 'https://example.com/installer.box',
268+
version: '2022',
269+
}), { message: 'Extracting the SQL Server 2022 installer did not produce setup.exe in C:/runner temp/sqlserver-setup-unique (found: readme.txt)' });
270+
assert.equal(tc.cacheDir.mock.callCount(), 0);
271+
});
272+
it('accepts an extracted SETUP.EXE regardless of case', async () => {
273+
readdir.mock.mockImplementation(async () => ['SETUP.EXE']);
274+
const res = await utils.downloadBoxInstaller({
275+
exeUrl: 'https://example.com/installer.exe',
276+
boxUrl: 'https://example.com/installer.box',
277+
version: '2022',
278+
});
279+
assert.match(res, /^C:\/tools\/[a-f0-9-]*\/setup\.exe$/);
280+
assert.equal(tc.cacheDir.mock.callCount(), 1);
281+
});
257282
it('throws if no boxUrl', async () => {
258283
await assert.rejects(() => utils.downloadBoxInstaller({
259284
exeUrl: 'https://example.com/installer.exe',
@@ -304,7 +329,7 @@ describe('utils', () => {
304329
describe('.downloadSseiInstaller()', () => {
305330
beforeEach(() => {
306331
tc.downloadTool.mock.mockImplementation(async () => `C:/tmp/${randomUUID()}.exe`);
307-
readdir.mock.mockImplementation(async () => ['SQLServer2025-x64-ENU.exe', 'SQLServer2025-x64-ENU.box']);
332+
readdir.mock.mockImplementation(mediaFiles(['SQLServer2025-x64-ENU.exe', 'SQLServer2025-x64-ENU.box']));
308333
});
309334
it('returns a path to an exe', async () => {
310335
const res = await utils.downloadSseiInstaller({
@@ -342,17 +367,18 @@ describe('utils', () => {
342367
['/qs', '/x:"C:/runner temp/sqlserver-media-unique/sqlserver-setup-unique"'],
343368
{ cwd: 'C:/runner temp/sqlserver-media-unique', windowsVerbatimArguments: true },
344369
]);
370+
assert.deepEqual(readdir.mock.calls[1].arguments, ['C:/runner temp/sqlserver-media-unique/sqlserver-setup-unique']);
345371
assert.deepEqual(tc.cacheDir.mock.calls[0].arguments, [
346372
'C:/runner temp/sqlserver-media-unique/sqlserver-setup-unique', 'sqlserver', '2025',
347373
]);
348374
});
349375
it('recognizes uppercase executable extensions', async () => {
350-
readdir.mock.mockImplementation(async () => ['SQLServer2025-x64-ENU.EXE']);
376+
readdir.mock.mockImplementation(mediaFiles(['SQLServer2025-x64-ENU.EXE']));
351377
await utils.downloadSseiInstaller({ sseiUrl: 'https://example.com/ssei.exe', version: '2025' });
352378
assert.equal(exec.exec.mock.calls[1].arguments[0], '"C:/tmp/sqlserver-media-unique/SQLServer2025-x64-ENU.EXE"');
353379
});
354380
it('rejects ambiguous installer media without extracting or caching it', async () => {
355-
readdir.mock.mockImplementation(async () => ['first.exe', 'second.exe']);
381+
readdir.mock.mockImplementation(mediaFiles(['first.exe', 'second.exe']));
356382
await assert.rejects(() => utils.downloadSseiInstaller({
357383
sseiUrl: 'https://example.com/ssei.exe', version: '2025',
358384
}), { message: 'SSEI bootstrapper produced multiple installer exes: first.exe, second.exe' });
@@ -380,13 +406,19 @@ describe('utils', () => {
380406
version: '2025',
381407
}), { message: 'No sseiUrl provided' });
382408
});
383-
it('throws if no exe found after download', async () => {
384-
readdir.mock.mockImplementation(async () => ['readme.txt', 'data.cab']);
385-
await assert.rejects(() => utils.downloadSseiInstaller({
386-
sseiUrl: 'https://example.com/ssei.exe',
387-
version: '2025',
388-
}), { message: 'SSEI bootstrapper did not produce an installer exe' });
389-
});
409+
for (const [files, found] of [
410+
[['readme.txt', 'data.cab'], 'readme.txt, data.cab'],
411+
[[], 'no files'],
412+
] as const) {
413+
it(`throws if no exe found after download (found: ${found})`, async () => {
414+
readdir.mock.mockImplementation(mediaFiles([...files]));
415+
await assert.rejects(() => utils.downloadSseiInstaller({
416+
sseiUrl: 'https://example.com/ssei.exe',
417+
version: '2025',
418+
}), { message: `SSEI bootstrapper did not produce an installer exe in C:/tmp/sqlserver-media-unique (found: ${found})` });
419+
assert.equal(tc.cacheDir.mock.callCount(), 0);
420+
});
421+
}
390422
it('calculates digests in debug mode', async () => {
391423
core.isDebug.mock.mockImplementation(() => true);
392424
const res = await utils.downloadSseiInstaller({
@@ -539,6 +571,18 @@ describe('utils', () => {
539571
await utils.downloadUpdateInstaller(config);
540572
assert.equal(tc.downloadTool.mock.calls[0].arguments[0], updateUrl);
541573
});
574+
for (const [files, problem] of [
575+
[[{ url: 'https://example.com/untrusted.exe' }, { url: 'https://example.com/untrusted.exe' }, { url: '' }, { url: 42 }], 'File list has no HTTPS download.microsoft.com .exe installer (found: https://example.com/untrusted.exe)'],
576+
[[], 'File list has no HTTPS download.microsoft.com .exe installer'],
577+
] as const) {
578+
it(`names metadata without a usable installer when no legacy link exists: ${problem}`, async () => {
579+
fetchResponse.text.mock.mockImplementation(async () => metadata(files));
580+
await assert.rejects(() => utils.downloadUpdateInstaller(config), {
581+
message: `No HTTPS download.microsoft.com .exe cumulative update installer found in Microsoft download page. Metadata problems: ${problem}`,
582+
});
583+
assert.ok(core.debug.mock.calls.some((call) => call.arguments[0] === `Unable to use cumulative update metadata: ${problem}`));
584+
});
585+
}
542586
it('uses valid metadata after an unusable metadata script', async () => {
543587
fetchResponse.text.mock.mockImplementation(async () => '<script>window.__DLCDetails__={invalid};</script>' + metadata([{ url: updateUrl }]));
544588
await utils.downloadUpdateInstaller(config);
@@ -628,6 +672,7 @@ describe('utils', () => {
628672
[new Error('connection reset'), 'connection reset'],
629673
[{ code: 'ENOTFOUND' }, 'ENOTFOUND'],
630674
[{ message: 'other side closed', code: 'UND_ERR_SOCKET' }, 'other side closed / UND_ERR_SOCKET'],
675+
[{ message: 'getaddrinfo ENOTFOUND download.example', code: 'ENOTFOUND' }, 'getaddrinfo ENOTFOUND download.example'],
631676
['ECONNRESET', 'ECONNRESET'],
632677
] as const) {
633678
it(`preserves the network failure cause: ${detail}`, async () => {

0 commit comments

Comments
 (0)