Skip to content

Commit cccfe3e

Browse files
authored
Merge pull request #196 from dhensby/feat/sql-server-2025
feat: add SQL Server 2025 support
2 parents aac168c + d857b36 commit cccfe3e

8 files changed

Lines changed: 162 additions & 18 deletions

File tree

.github/copilot-instructions.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,14 @@ The build step (`npm run build`) also runs `npm run docs`, which regenerates the
4343
1. Reads action inputs via `gatherInputs()` from `src/utils.ts`
4444
2. Validates OS compatibility using version config from `src/versions.ts`
4545
3. Optionally installs SQL Native Client (`src/install-native-client.ts`) and ODBC driver (`src/install-odbc.ts`)
46-
4. Downloads or cache-hits the SQL Server installer (box+exe or standalone exe)
46+
4. Downloads or cache-hits the SQL Server installer (box+exe, standalone exe, or SSEI bootstrapper)
4747
5. Optionally downloads cumulative updates
4848
6. Runs the installer via `@actions/exec`
4949
7. Waits for the database to be ready (exponential backoff)
5050

51-
**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 in `src/utils.ts`.
51+
**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`.
5252

53-
**Version registry:** `src/versions.ts` defines a `Map<string, VersionConfig>` with download URLs, optional box URLs, update URLs, and OS compatibility constraints for each supported SQL Server version (2008–2022).
53+
**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.
5454

5555
**Build output:** `@vercel/ncc` bundles everything into `lib/main/index.js`, 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.
5656

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.

src/install.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import { readFile } from 'node:fs/promises';
33
import * as core from '@actions/core';
44
import * as exec from '@actions/exec';
55
import * as tc from '@actions/tool-cache';
6-
import { VersionConfig, VERSIONS } from './versions';
6+
import { type VersionConfig, VERSIONS } from './versions';
77
import {
88
downloadBoxInstaller,
99
downloadExeInstaller,
10+
downloadSseiInstaller,
1011
downloadUpdateInstaller,
1112
gatherInputs,
1213
gatherSummaryFiles,
@@ -27,6 +28,8 @@ function findOrDownloadTool(config: VersionConfig): Promise<string> {
2728
if (toolPath) {
2829
core.info(`Found in cache @ ${toolPath}`);
2930
return Promise.resolve(joinPaths(toolPath, 'setup.exe'));
31+
} else if (config.sseiUrl) {
32+
return downloadSseiInstaller(config);
3033
} else if (config.boxUrl) {
3134
return downloadBoxInstaller(config);
3235
}

src/utils.ts

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import * as exec from '@actions/exec';
1+
import { basename, extname, dirname, join as joinPaths } from 'node:path';
2+
import { readdir } from 'node:fs/promises';
23
import * as core from '@actions/core';
3-
import * as tc from '@actions/tool-cache';
4-
import * as io from '@actions/io';
4+
import * as exec from '@actions/exec';
5+
import * as glob from '@actions/glob';
56
import * as http from '@actions/http-client';
6-
import { basename, extname, dirname, join as joinPaths } from 'node:path';
7-
import { VersionConfig } from './versions';
7+
import * as io from '@actions/io';
8+
import * as tc from '@actions/tool-cache';
89
import { generateFileHash } from './crypto';
9-
import * as glob from '@actions/glob';
10+
import type { VersionConfig } from './versions';
1011

1112
/**
1213
* Helper function to determine the runner being used. Uses `systeminfo` to gather version.
@@ -56,7 +57,7 @@ export interface Inputs {
5657
export function gatherInputs(): Inputs {
5758
const version = core.getInput('sqlserver-version').replace(/sql-/i, '') || 'latest';
5859
return {
59-
version: version.toLowerCase() === 'latest' ? '2022' : version,
60+
version: version.toLowerCase() === 'latest' ? '2025' : version,
6061
password: core.getInput('sa-password'),
6162
collation: core.getInput('db-collation'),
6263
installArgs: core.getMultilineInput('install-arguments'),
@@ -119,6 +120,9 @@ export async function downloadBoxInstaller(config: VersionConfig): Promise<strin
119120
if (!config.boxUrl) {
120121
throw new Error('No boxUrl provided');
121122
}
123+
if (!config.exeUrl) {
124+
throw new Error('No exeUrl provided');
125+
}
122126
const [exePath, boxPath] = await Promise.all([
123127
downloadTool(config.exeUrl),
124128
downloadTool(config.boxUrl),
@@ -146,6 +150,57 @@ export async function downloadBoxInstaller(config: VersionConfig): Promise<strin
146150
return joinPaths(toolPath, 'setup.exe');
147151
}
148152

153+
/**
154+
* Downloads install media using the SSEI bootstrapper. The bootstrapper is
155+
* downloaded and then executed with /Action=Download to fetch the CAB media,
156+
* which is then extracted in the same way as the box installer.
157+
*
158+
* @param {VersionConfig} config
159+
* @returns {Promise<string>} The path to the installer executable
160+
*/
161+
export async function downloadSseiInstaller(config: VersionConfig): Promise<string> {
162+
if (!config.sseiUrl) {
163+
throw new Error('No sseiUrl provided');
164+
}
165+
// download the SSEI bootstrapper
166+
const sseiPath = await downloadTool(config.sseiUrl);
167+
if (core.isDebug()) {
168+
const hash = await generateFileHash(sseiPath);
169+
core.debug(`Got SSEI bootstrapper with hash SHA256=${hash.toString('base64')}`);
170+
}
171+
// use the bootstrapper to download the actual media
172+
const mediaDir = dirname(sseiPath);
173+
core.info('Downloading install media via SSEI bootstrapper');
174+
await exec.exec(`"${sseiPath}"`, [
175+
'/Action=Download',
176+
`/MediaPath=${mediaDir}`,
177+
'/MediaType=CAB',
178+
'/Quiet',
179+
'/Language=en-US',
180+
], {
181+
windowsVerbatimArguments: true,
182+
});
183+
// find the downloaded exe in the media directory
184+
const files = await readdir(mediaDir);
185+
const exeFile = files.find((f) => f.endsWith('.exe') && f !== basename(sseiPath));
186+
if (!exeFile) {
187+
throw new Error('SSEI bootstrapper did not produce an installer exe');
188+
}
189+
const exePath = joinPaths(mediaDir, exeFile);
190+
core.info('Extracting installer');
191+
await exec.exec(`"${exePath}"`, [
192+
'/qs',
193+
'/x:setup',
194+
], {
195+
cwd: mediaDir,
196+
windowsVerbatimArguments: true,
197+
});
198+
core.info('Adding to the cache');
199+
const toolPath = await tc.cacheDir(joinPaths(mediaDir, 'setup'), 'sqlserver', config.version);
200+
core.debug(`Cached @ ${toolPath}`);
201+
return joinPaths(toolPath, 'setup.exe');
202+
}
203+
149204
/**
150205
* Downloads an EXE installer
151206
*
@@ -156,6 +211,9 @@ export async function downloadExeInstaller(config: VersionConfig): Promise<strin
156211
if (config.boxUrl) {
157212
throw new Error('Version requires box installer');
158213
}
214+
if (!config.exeUrl) {
215+
throw new Error('No exeUrl provided');
216+
}
159217
const exePath = await downloadTool(config.exeUrl);
160218
if (core.isDebug()) {
161219
const hash = await generateFileHash(exePath);

src/versions.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,19 @@ interface Config {
1010
}
1111

1212
export interface VersionConfig extends Config {
13-
exeUrl: string;
13+
exeUrl?: string;
1414
boxUrl?: string;
15+
sseiUrl?: string;
1516
updateUrl?: string;
1617
}
1718

1819
export const VERSIONS = new Map<string, VersionConfig>(
1920
[
21+
['2025', {
22+
version: '2025',
23+
sseiUrl: 'https://download.microsoft.com/download/77dc60d3-0139-4dad-83c8-bb52ab22db01/SQL2025-SSEI-StdDev.exe',
24+
// updateUrl can be added once Microsoft publishes cumulative updates for 2025
25+
}],
2026
['2022', {
2127
version: '2022',
2228
exeUrl: 'https://download.microsoft.com/download/3/8/d/38de7036-2433-4207-8eae-06e247e17b25/SQLServer2022-DEV-x64-ENU.exe',

test/install.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ describe('install', () => {
2525
stubNc = stub(nativeClient);
2626
stubOdbc = stub(odbcDriver);
2727
versionStub = stub(versions.VERSIONS);
28-
versionStub.keys.returns(['box', 'exe', 'maxOs', 'minOs', 'minMaxOs'][Symbol.iterator]());
28+
versionStub.keys.returns(['box', 'exe', 'ssei', 'maxOs', 'minOs', 'minMaxOs'][Symbol.iterator]());
2929
versionStub.has.callsFake((name) => {
30-
return ['box', 'exe', 'maxOs', 'minOs', 'minMaxOs'].includes(name);
30+
return ['box', 'exe', 'ssei', 'maxOs', 'minOs', 'minMaxOs'].includes(name);
3131
});
3232
versionStub.get.withArgs('box').returns({
3333
version: '2022',
@@ -40,6 +40,11 @@ describe('install', () => {
4040
exeUrl: 'https://example.com/setup.exe',
4141
updateUrl: 'https://example.com/update.exe',
4242
});
43+
versionStub.get.withArgs('ssei').returns({
44+
version: '2025',
45+
sseiUrl: 'https://example.com/ssei.exe',
46+
updateUrl: 'https://example.com/update.html',
47+
});
4348
versionStub.get.withArgs('maxOs').returns({
4449
version: '2017',
4550
exeUrl: 'https://example.com/setup.exe',
@@ -78,6 +83,7 @@ describe('install', () => {
7883
utilsStub.gatherSummaryFiles.resolves([]);
7984
utilsStub.downloadExeInstaller.resolves('C:/tmp/exe/setup.exe');
8085
utilsStub.downloadBoxInstaller.resolves('C:/tmp/box/setup.exe');
86+
utilsStub.downloadSseiInstaller.resolves('C:/tmp/ssei/setup.exe');
8187
utilsStub.downloadUpdateInstaller.resolves('C:/tmp/exe/sqlupdate.exe');
8288
utilsStub.waitForDatabase.resolves(0);
8389
coreStub = stub(core);
@@ -124,7 +130,7 @@ describe('install', () => {
124130
try {
125131
await install();
126132
} catch (e) {
127-
expect(e).to.have.property('message', 'Unsupported SQL Version, supported versions are box, exe, maxOs, minOs, minMaxOs, got: missing');
133+
expect(e).to.have.property('message', 'Unsupported SQL Version, supported versions are box, exe, ssei, maxOs, minOs, minMaxOs, got: missing');
128134
return;
129135
}
130136
expect.fail('expected to throw');
@@ -148,6 +154,21 @@ describe('install', () => {
148154
await install();
149155
expect(execStub.exec).to.have.been.calledWith('"C:/tmp/exe/setup.exe"', match.array, { windowsVerbatimArguments: true });
150156
});
157+
it('runs an ssei install', async () => {
158+
utilsStub.gatherInputs.returns({
159+
version: 'ssei',
160+
password: 'secret password',
161+
collation: 'SQL_Latin1_General_CP1_CI_AS',
162+
installArgs: [],
163+
wait: true,
164+
skipOsCheck: false,
165+
nativeClientVersion: '',
166+
odbcVersion: '',
167+
installUpdates: false,
168+
});
169+
await install();
170+
expect(execStub.exec).to.have.been.calledWith('"C:/tmp/ssei/setup.exe"', match.array, { windowsVerbatimArguments: true });
171+
});
151172
it('downloads cumulative updates', async () => {
152173
utilsStub.gatherInputs.returns({
153174
version: 'exe',

test/utils.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { randomBytes, randomUUID } from 'node:crypto';
2+
import fs from 'node:fs/promises';
23
import { IncomingMessage } from 'node:http';
34
import * as exec from '@actions/exec';
45
import * as core from '@actions/core';
@@ -141,7 +142,7 @@ describe('utils', () => {
141142
coreStub.getBooleanInput.withArgs('install-updates').returns(false);
142143
const res = utils.gatherInputs();
143144
expect(res).to.deep.equal({
144-
version: '2022',
145+
version: '2025',
145146
password: 'secret password',
146147
collation: 'SQL_Latin1_General_CP1_CI_AS',
147148
installArgs: [],
@@ -164,7 +165,7 @@ describe('utils', () => {
164165
coreStub.getBooleanInput.withArgs('install-updates').returns(false);
165166
const res = utils.gatherInputs();
166167
expect(res).to.deep.equal({
167-
version: '2022',
168+
version: '2025',
168169
password: 'secret password',
169170
collation: 'SQL_Latin1_General_CP1_CI_AS',
170171
installArgs: [],
@@ -290,6 +291,60 @@ describe('utils', () => {
290291
expect(res).to.match(/^C:\/tools\/[a-f0-9-]*\/setup\.exe$/);
291292
});
292293
});
294+
describe('.downloadSseiInstaller()', () => {
295+
beforeEach('stub deps', () => {
296+
stub(tc, 'downloadTool').callsFake(() => Promise.resolve(`C:/tmp/${randomUUID()}.exe`));
297+
stub(exec, 'exec').resolves(0);
298+
stub(io, 'mv').resolves();
299+
stub(tc, 'cacheDir').callsFake(() => Promise.resolve(`C:/tools/${randomUUID()}`));
300+
stub(crypto, 'generateFileHash').callsFake(() => Promise.resolve(randomBytes(32)));
301+
stub(fs, 'readdir').resolves(['ssei-bootstrapper.exe', 'SQLServer2025-x64-ENU.exe'] as unknown as []);
302+
});
303+
it('returns a path to an exe', async () => {
304+
const res = await utils.downloadSseiInstaller({
305+
sseiUrl: 'https://example.com/ssei.exe',
306+
version: '2025',
307+
});
308+
expect(res).to.match(/^C:\/tools\/[a-f0-9-]*\/setup\.exe$/);
309+
});
310+
it('throws if no sseiUrl', async () => {
311+
try {
312+
await utils.downloadSseiInstaller({
313+
version: '2025',
314+
});
315+
} catch (e) {
316+
expect(e).to.have.property('message', 'No sseiUrl provided');
317+
return;
318+
}
319+
expect.fail('expected to fail');
320+
});
321+
it('throws if no exe found after download', async () => {
322+
(fs.readdir as SinonStub).resolves(['readme.txt', 'data.cab'] as unknown as []);
323+
try {
324+
await utils.downloadSseiInstaller({
325+
sseiUrl: 'https://example.com/ssei.exe',
326+
version: '2025',
327+
});
328+
} catch (e) {
329+
expect(e).to.have.property('message', 'SSEI bootstrapper did not produce an installer exe');
330+
return;
331+
}
332+
expect.fail('expected to fail');
333+
});
334+
it('calculates digests in debug mode', async () => {
335+
coreStub.isDebug.returns(true);
336+
const res = await utils.downloadSseiInstaller({
337+
sseiUrl: 'https://example.com/ssei.exe',
338+
version: '2025',
339+
});
340+
const calls = coreStub.debug.getCalls().filter(({ firstArg }) => {
341+
return firstArg.startsWith('Got SSEI bootstrapper');
342+
});
343+
expect(calls).to.have.lengthOf(1);
344+
expect(calls[0].firstArg).to.match(/^Got SSEI bootstrapper with hash SHA256=/);
345+
expect(res).to.match(/^C:\/tools\/[a-f0-9-]*\/setup\.exe$/);
346+
});
347+
});
293348
describe('.downloadUpdateInstaller()', () => {
294349
let stubClient: SinonStubbedInstance<http.HttpClient>;
295350
let stubResponse: SinonStubbedInstance<http.HttpClientResponse>;

test/versions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ describe('versions', () => {
55
describe('VERSIONS', () => {
66
it('exports the supported versions', () => {
77
assert.deepEqual(Array.from(versions.VERSIONS.keys()), [
8+
'2025',
89
'2022',
910
'2019',
1011
'2017',

0 commit comments

Comments
 (0)