Skip to content
Open
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
4 changes: 2 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,13 @@ 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 (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)
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)
6. Runs the installer via `@actions/exec`
7. Waits for the database to be ready (exponential backoff)

**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`.

**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.
**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.

**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.

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ See [action.yml](./action.yml):
skip-os-check: false

# SQL Server version: 2008, 2012, 2014, 2016, 2017, 2019, 2022, or 2025. "latest"
# selects 2025.
# 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.
# Default: latest
sqlserver-version: 'latest'

Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ inputs:
description: 'Skip OS checks that will stop installation attempts preemptively.'
default: 'false'
sqlserver-version:
description: 'SQL Server version: 2008, 2012, 2014, 2016, 2017, 2019, 2022, or 2025. "latest" selects 2025.'
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.'
default: 'latest'
native-client-version:
description: 'Version of native client to install. Only 11 is supported.'
Expand Down
2 changes: 1 addition & 1 deletion 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.

2 changes: 1 addition & 1 deletion src/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export default async function install() {
message += `windows-${max}`;
}
message += '.';
throw new Error(`Runner version windows-${osVersion} is not supported for SQL Server ${version}. ${message}`);
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.`);
}
}
if (nativeClientVersion) {
Expand Down
49 changes: 36 additions & 13 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,11 @@ async function extractAndCacheInstaller(exePath: string, version: string): Promi
cwd: workDir,
windowsVerbatimArguments: true,
});
// don't cache an extraction that produced no setup.exe
const extracted = await readdir(setupDir);
if (!extracted.some((file) => file.toLowerCase() === 'setup.exe')) {
throw new Error(`Extracting the SQL Server ${version} installer did not produce setup.exe in ${setupDir} (found: ${extracted.join(', ') || 'no files'})`);
}
core.info('Adding to the cache');
const toolPath = await tc.cacheDir(setupDir, 'sqlserver', version);
core.debug(`Cached @ ${toolPath}`);
Expand Down Expand Up @@ -198,7 +203,7 @@ export async function downloadSseiInstaller(config: VersionConfig): Promise<stri
const files = await readdir(mediaDir);
const exeFiles = files.filter((file) => file.toLowerCase().endsWith('.exe'));
if (exeFiles.length === 0) {
throw new Error('SSEI bootstrapper did not produce an installer exe');
throw new Error(`SSEI bootstrapper did not produce an installer exe in ${mediaDir} (found: ${files.join(', ') || 'no files'})`);
}
if (exeFiles.length > 1) {
throw new Error(`SSEI bootstrapper produced multiple installer exes: ${exeFiles.join(', ')}`);
Expand Down Expand Up @@ -260,10 +265,14 @@ function extractUpdateDownloadUrl(body: string): string {
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);
}
const urls = files.filter(isRecord).map((file) => file.url);
const installerUrls = urls.filter(isUpdateDownloadUrl);
if (!installerUrls.length) {
const listed = [...new Set(urls.filter((url): url is string => typeof url === 'string' && url.length > 0))];
throw new Error(`File list has no HTTPS download.microsoft.com .exe installer${listed.length ? ` (found: ${listed.join(', ')})` : ''}`);
}
for (const url of installerUrls) {
links.add(url);
}
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
Expand All @@ -289,6 +298,25 @@ function extractUpdateDownloadUrl(body: string): string {
return link;
}

/**
* Summarise a fetch failure's cause, leaving out the error code when the
* message already contains it (e.g. "getaddrinfo ENOTFOUND host").
*
* @param {unknown} cause
* @returns {string}
*/
function describeCause(cause: unknown): string {
if (typeof cause === 'string') {
return cause;
}
if (!isRecord(cause)) {
return '';
}
const message = typeof cause.message === 'string' ? cause.message : '';
const code = typeof cause.code === 'string' && !message.includes(cause.code) ? cause.code : '';
return [message, code].filter(Boolean).join(' / ');
}

async function fetchUpdatePage(url: string): Promise<string> {
const attempts = 3;
for (let attempt = 1; ; attempt++) {
Expand All @@ -303,14 +331,9 @@ async function fetchUpdatePage(url: string): Promise<string> {
} 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})`;
}
const causeDetails = error instanceof Error ? describeCause(error.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 });
Expand Down
6 changes: 3 additions & 3 deletions test/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,21 +209,21 @@ describe('install', () => {
it('errors on os max support', async () => {
utils.gatherInputs.mock.mockImplementation(() => defaultInputs({ version: 'maxOs' }));
await assert.rejects(() => install(), {
message: 'Runner version windows-2022 is not supported for SQL Server maxOs. Please use windows-2019.',
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.',
});
});
it('errors on os min support', async () => {
utils.getOsVersion.mock.mockImplementation(async () => 2019);
utils.gatherInputs.mock.mockImplementation(() => defaultInputs({ version: 'minOs' }));
await assert.rejects(() => install(), {
message: 'Runner version windows-2019 is not supported for SQL Server minOs. Please use windows-2022.',
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.',
});
});
it('errors on os min & max support', async () => {
utils.getOsVersion.mock.mockImplementation(async () => 2016);
utils.gatherInputs.mock.mockImplementation(() => defaultInputs({ version: 'minMaxOs' }));
await assert.rejects(() => install(), {
message: 'Runner version windows-2016 is not supported for SQL Server minMaxOs. Please use windows-2019 to windows-2022.',
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.',
});
});
it('continues if no os version found', async () => {
Expand Down
69 changes: 57 additions & 12 deletions test/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ const fetchMock = mock.fn(async () => fetchResponse);
globalThis.fetch = fetchMock as unknown as typeof fetch;

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

Expand Down Expand Up @@ -77,7 +81,7 @@ function resetAll() {
fetchResponse.text.mock.mockImplementation(async () => '');
fetchMock.mock.mockImplementation(async () => fetchResponse);
mkdtemp.mock.mockImplementation(async (prefix: string) => `${prefix}unique`);
readdir.mock.mockImplementation(async () => []);
readdir.mock.mockImplementation(mediaFiles([]));
generateFileHash.mock.mockImplementation(async () => randomBytes(32));
}

Expand Down Expand Up @@ -250,10 +254,31 @@ describe('utils', () => {
['/qs', '/x:"C:/runner temp/sqlserver-setup-unique"'],
{ cwd: 'C:/runner temp', windowsVerbatimArguments: true },
]);
assert.deepEqual(readdir.mock.calls[0].arguments, ['C:/runner temp/sqlserver-setup-unique']);
assert.deepEqual(tc.cacheDir.mock.calls[0].arguments, [
'C:/runner temp/sqlserver-setup-unique', 'sqlserver', '2022',
]);
});
it('fails without caching if extraction does not produce setup.exe', async () => {
tc.downloadTool.mock.mockImplementation(async () => 'C:/runner temp/installer');
readdir.mock.mockImplementation(async () => ['readme.txt']);
await assert.rejects(() => utils.downloadBoxInstaller({
exeUrl: 'https://example.com/installer.exe',
boxUrl: 'https://example.com/installer.box',
version: '2022',
}), { message: 'Extracting the SQL Server 2022 installer did not produce setup.exe in C:/runner temp/sqlserver-setup-unique (found: readme.txt)' });
assert.equal(tc.cacheDir.mock.callCount(), 0);
});
it('accepts an extracted SETUP.EXE regardless of case', async () => {
readdir.mock.mockImplementation(async () => ['SETUP.EXE']);
const res = await utils.downloadBoxInstaller({
exeUrl: 'https://example.com/installer.exe',
boxUrl: 'https://example.com/installer.box',
version: '2022',
});
assert.match(res, /^C:\/tools\/[a-f0-9-]*\/setup\.exe$/);
assert.equal(tc.cacheDir.mock.callCount(), 1);
});
it('throws if no boxUrl', async () => {
await assert.rejects(() => utils.downloadBoxInstaller({
exeUrl: 'https://example.com/installer.exe',
Expand Down Expand Up @@ -304,7 +329,7 @@ describe('utils', () => {
describe('.downloadSseiInstaller()', () => {
beforeEach(() => {
tc.downloadTool.mock.mockImplementation(async () => `C:/tmp/${randomUUID()}.exe`);
readdir.mock.mockImplementation(async () => ['SQLServer2025-x64-ENU.exe', 'SQLServer2025-x64-ENU.box']);
readdir.mock.mockImplementation(mediaFiles(['SQLServer2025-x64-ENU.exe', 'SQLServer2025-x64-ENU.box']));
});
it('returns a path to an exe', async () => {
const res = await utils.downloadSseiInstaller({
Expand Down Expand Up @@ -342,17 +367,18 @@ describe('utils', () => {
['/qs', '/x:"C:/runner temp/sqlserver-media-unique/sqlserver-setup-unique"'],
{ cwd: 'C:/runner temp/sqlserver-media-unique', windowsVerbatimArguments: true },
]);
assert.deepEqual(readdir.mock.calls[1].arguments, ['C:/runner temp/sqlserver-media-unique/sqlserver-setup-unique']);
assert.deepEqual(tc.cacheDir.mock.calls[0].arguments, [
'C:/runner temp/sqlserver-media-unique/sqlserver-setup-unique', 'sqlserver', '2025',
]);
});
it('recognizes uppercase executable extensions', async () => {
readdir.mock.mockImplementation(async () => ['SQLServer2025-x64-ENU.EXE']);
readdir.mock.mockImplementation(mediaFiles(['SQLServer2025-x64-ENU.EXE']));
await utils.downloadSseiInstaller({ sseiUrl: 'https://example.com/ssei.exe', version: '2025' });
assert.equal(exec.exec.mock.calls[1].arguments[0], '"C:/tmp/sqlserver-media-unique/SQLServer2025-x64-ENU.EXE"');
});
it('rejects ambiguous installer media without extracting or caching it', async () => {
readdir.mock.mockImplementation(async () => ['first.exe', 'second.exe']);
readdir.mock.mockImplementation(mediaFiles(['first.exe', 'second.exe']));
await assert.rejects(() => utils.downloadSseiInstaller({
sseiUrl: 'https://example.com/ssei.exe', version: '2025',
}), { message: 'SSEI bootstrapper produced multiple installer exes: first.exe, second.exe' });
Expand Down Expand Up @@ -380,13 +406,19 @@ describe('utils', () => {
version: '2025',
}), { message: 'No sseiUrl provided' });
});
it('throws if no exe found after download', async () => {
readdir.mock.mockImplementation(async () => ['readme.txt', 'data.cab']);
await assert.rejects(() => utils.downloadSseiInstaller({
sseiUrl: 'https://example.com/ssei.exe',
version: '2025',
}), { message: 'SSEI bootstrapper did not produce an installer exe' });
});
for (const [files, found] of [
[['readme.txt', 'data.cab'], 'readme.txt, data.cab'],
[[], 'no files'],
] as const) {
it(`throws if no exe found after download (found: ${found})`, async () => {
readdir.mock.mockImplementation(mediaFiles([...files]));
await assert.rejects(() => utils.downloadSseiInstaller({
sseiUrl: 'https://example.com/ssei.exe',
version: '2025',
}), { message: `SSEI bootstrapper did not produce an installer exe in C:/tmp/sqlserver-media-unique (found: ${found})` });
assert.equal(tc.cacheDir.mock.callCount(), 0);
});
}
it('calculates digests in debug mode', async () => {
core.isDebug.mock.mockImplementation(() => true);
const res = await utils.downloadSseiInstaller({
Expand Down Expand Up @@ -539,6 +571,18 @@ describe('utils', () => {
await utils.downloadUpdateInstaller(config);
assert.equal(tc.downloadTool.mock.calls[0].arguments[0], updateUrl);
});
for (const [files, problem] of [
[[{ 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)'],
[[], 'File list has no HTTPS download.microsoft.com .exe installer'],
] as const) {
it(`names metadata without a usable installer when no legacy link exists: ${problem}`, async () => {
fetchResponse.text.mock.mockImplementation(async () => metadata(files));
await assert.rejects(() => utils.downloadUpdateInstaller(config), {
message: `No HTTPS download.microsoft.com .exe cumulative update installer found in Microsoft download page. Metadata problems: ${problem}`,
});
assert.ok(core.debug.mock.calls.some((call) => call.arguments[0] === `Unable to use cumulative update metadata: ${problem}`));
});
}
it('uses valid metadata after an unusable metadata script', async () => {
fetchResponse.text.mock.mockImplementation(async () => '<script>window.__DLCDetails__={invalid};</script>' + metadata([{ url: updateUrl }]));
await utils.downloadUpdateInstaller(config);
Expand Down Expand Up @@ -628,6 +672,7 @@ describe('utils', () => {
[new Error('connection reset'), 'connection reset'],
[{ code: 'ENOTFOUND' }, 'ENOTFOUND'],
[{ message: 'other side closed', code: 'UND_ERR_SOCKET' }, 'other side closed / UND_ERR_SOCKET'],
[{ message: 'getaddrinfo ENOTFOUND download.example', code: 'ENOTFOUND' }, 'getaddrinfo ENOTFOUND download.example'],
['ECONNRESET', 'ECONNRESET'],
] as const) {
it(`preserves the network failure cause: ${detail}`, async () => {
Expand Down