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
6 changes: 3 additions & 3 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,14 @@ The build step (`npm run build`) also runs `npm run docs`, which regenerates the
1. Reads action inputs via `gatherInputs()` from `src/utils.ts`
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 or standalone exe)
4. Downloads or cache-hits the SQL Server installer (box+exe, standalone exe, or SSEI bootstrapper)
5. Optionally downloads cumulative 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 in `src/utils.ts`.
**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, optional box URLs, update URLs, and OS compatibility constraints for each supported SQL Server version (2008–2022).
**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.

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

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

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { readFile } from 'node:fs/promises';
import * as core from '@actions/core';
import * as exec from '@actions/exec';
import * as tc from '@actions/tool-cache';
import { VersionConfig, VERSIONS } from './versions';
import { type VersionConfig, VERSIONS } from './versions';
import {
downloadBoxInstaller,
downloadExeInstaller,
downloadSseiInstaller,
downloadUpdateInstaller,
gatherInputs,
gatherSummaryFiles,
Expand All @@ -27,6 +28,8 @@ function findOrDownloadTool(config: VersionConfig): Promise<string> {
if (toolPath) {
core.info(`Found in cache @ ${toolPath}`);
return Promise.resolve(joinPaths(toolPath, 'setup.exe'));
} else if (config.sseiUrl) {
return downloadSseiInstaller(config);
} else if (config.boxUrl) {
return downloadBoxInstaller(config);
}
Expand Down
72 changes: 65 additions & 7 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import * as exec from '@actions/exec';
import { basename, extname, dirname, join as joinPaths } from 'node:path';
import { readdir } from 'node:fs/promises';
import * as core from '@actions/core';
import * as tc from '@actions/tool-cache';
import * as io from '@actions/io';
import * as exec from '@actions/exec';
import * as glob from '@actions/glob';
import * as http from '@actions/http-client';
import { basename, extname, dirname, join as joinPaths } from 'node:path';
import { VersionConfig } from './versions';
import * as io from '@actions/io';
import * as tc from '@actions/tool-cache';
import { generateFileHash } from './crypto';
import * as glob from '@actions/glob';
import type { VersionConfig } from './versions';

/**
* Helper function to determine the runner being used. Uses `systeminfo` to gather version.
Expand Down Expand Up @@ -56,7 +57,7 @@ export interface Inputs {
export function gatherInputs(): Inputs {
const version = core.getInput('sqlserver-version').replace(/sql-/i, '') || 'latest';
return {
version: version.toLowerCase() === 'latest' ? '2022' : version,
version: version.toLowerCase() === 'latest' ? '2025' : version,
password: core.getInput('sa-password'),
collation: core.getInput('db-collation'),
installArgs: core.getMultilineInput('install-arguments'),
Expand Down Expand Up @@ -119,6 +120,9 @@ export async function downloadBoxInstaller(config: VersionConfig): Promise<strin
if (!config.boxUrl) {
throw new Error('No boxUrl provided');
}
if (!config.exeUrl) {
throw new Error('No exeUrl provided');
}
const [exePath, boxPath] = await Promise.all([
downloadTool(config.exeUrl),
downloadTool(config.boxUrl),
Expand Down Expand Up @@ -146,6 +150,57 @@ export async function downloadBoxInstaller(config: VersionConfig): Promise<strin
return joinPaths(toolPath, 'setup.exe');
}

/**
* Downloads install media using the SSEI bootstrapper. The bootstrapper is
* downloaded and then executed with /Action=Download to fetch the CAB media,
* which is then extracted in the same way as the box installer.
*
* @param {VersionConfig} config
* @returns {Promise<string>} The path to the installer executable
*/
export async function downloadSseiInstaller(config: VersionConfig): Promise<string> {
if (!config.sseiUrl) {
throw new Error('No sseiUrl provided');
}
// download the SSEI bootstrapper
const sseiPath = await downloadTool(config.sseiUrl);
if (core.isDebug()) {
const hash = await generateFileHash(sseiPath);
core.debug(`Got SSEI bootstrapper with hash SHA256=${hash.toString('base64')}`);
}
// use the bootstrapper to download the actual media
const mediaDir = dirname(sseiPath);
core.info('Downloading install media via SSEI bootstrapper');
await exec.exec(`"${sseiPath}"`, [
'/Action=Download',
`/MediaPath=${mediaDir}`,
'/MediaType=CAB',
'/Quiet',
'/Language=en-US',
], {
windowsVerbatimArguments: true,
});
// find the downloaded exe in the media directory
const files = await readdir(mediaDir);
const exeFile = files.find((f) => f.endsWith('.exe') && f !== basename(sseiPath));
if (!exeFile) {
throw new Error('SSEI bootstrapper did not produce an installer exe');
}
const exePath = joinPaths(mediaDir, exeFile);
core.info('Extracting installer');
await exec.exec(`"${exePath}"`, [
'/qs',
'/x:setup',
], {
cwd: mediaDir,
windowsVerbatimArguments: true,
});
core.info('Adding to the cache');
const toolPath = await tc.cacheDir(joinPaths(mediaDir, 'setup'), 'sqlserver', config.version);
core.debug(`Cached @ ${toolPath}`);
return joinPaths(toolPath, 'setup.exe');
}

/**
* Downloads an EXE installer
*
Expand All @@ -156,6 +211,9 @@ export async function downloadExeInstaller(config: VersionConfig): Promise<strin
if (config.boxUrl) {
throw new Error('Version requires box installer');
}
if (!config.exeUrl) {
throw new Error('No exeUrl provided');
}
const exePath = await downloadTool(config.exeUrl);
if (core.isDebug()) {
const hash = await generateFileHash(exePath);
Expand Down
8 changes: 7 additions & 1 deletion src/versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ interface Config {
}

export interface VersionConfig extends Config {
exeUrl: string;
exeUrl?: string;
boxUrl?: string;
sseiUrl?: string;
updateUrl?: string;
}

export const VERSIONS = new Map<string, VersionConfig>(
[
['2025', {
version: '2025',
sseiUrl: 'https://download.microsoft.com/download/77dc60d3-0139-4dad-83c8-bb52ab22db01/SQL2025-SSEI-StdDev.exe',
// updateUrl can be added once Microsoft publishes cumulative updates for 2025
}],
['2022', {
version: '2022',
exeUrl: 'https://download.microsoft.com/download/3/8/d/38de7036-2433-4207-8eae-06e247e17b25/SQLServer2022-DEV-x64-ENU.exe',
Expand Down
27 changes: 24 additions & 3 deletions test/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ describe('install', () => {
stubNc = stub(nativeClient);
stubOdbc = stub(odbcDriver);
versionStub = stub(versions.VERSIONS);
versionStub.keys.returns(['box', 'exe', 'maxOs', 'minOs', 'minMaxOs'][Symbol.iterator]());
versionStub.keys.returns(['box', 'exe', 'ssei', 'maxOs', 'minOs', 'minMaxOs'][Symbol.iterator]());
versionStub.has.callsFake((name) => {
return ['box', 'exe', 'maxOs', 'minOs', 'minMaxOs'].includes(name);
return ['box', 'exe', 'ssei', 'maxOs', 'minOs', 'minMaxOs'].includes(name);
});
versionStub.get.withArgs('box').returns({
version: '2022',
Expand All @@ -40,6 +40,11 @@ describe('install', () => {
exeUrl: 'https://example.com/setup.exe',
updateUrl: 'https://example.com/update.exe',
});
versionStub.get.withArgs('ssei').returns({
version: '2025',
sseiUrl: 'https://example.com/ssei.exe',
updateUrl: 'https://example.com/update.html',
});
versionStub.get.withArgs('maxOs').returns({
version: '2017',
exeUrl: 'https://example.com/setup.exe',
Expand Down Expand Up @@ -78,6 +83,7 @@ describe('install', () => {
utilsStub.gatherSummaryFiles.resolves([]);
utilsStub.downloadExeInstaller.resolves('C:/tmp/exe/setup.exe');
utilsStub.downloadBoxInstaller.resolves('C:/tmp/box/setup.exe');
utilsStub.downloadSseiInstaller.resolves('C:/tmp/ssei/setup.exe');
utilsStub.downloadUpdateInstaller.resolves('C:/tmp/exe/sqlupdate.exe');
utilsStub.waitForDatabase.resolves(0);
coreStub = stub(core);
Expand Down Expand Up @@ -124,7 +130,7 @@ describe('install', () => {
try {
await install();
} catch (e) {
expect(e).to.have.property('message', 'Unsupported SQL Version, supported versions are box, exe, maxOs, minOs, minMaxOs, got: missing');
expect(e).to.have.property('message', 'Unsupported SQL Version, supported versions are box, exe, ssei, maxOs, minOs, minMaxOs, got: missing');
return;
}
expect.fail('expected to throw');
Expand All @@ -148,6 +154,21 @@ describe('install', () => {
await install();
expect(execStub.exec).to.have.been.calledWith('"C:/tmp/exe/setup.exe"', match.array, { windowsVerbatimArguments: true });
});
it('runs an ssei install', async () => {
utilsStub.gatherInputs.returns({
version: 'ssei',
password: 'secret password',
collation: 'SQL_Latin1_General_CP1_CI_AS',
installArgs: [],
wait: true,
skipOsCheck: false,
nativeClientVersion: '',
odbcVersion: '',
installUpdates: false,
});
await install();
expect(execStub.exec).to.have.been.calledWith('"C:/tmp/ssei/setup.exe"', match.array, { windowsVerbatimArguments: true });
});
it('downloads cumulative updates', async () => {
utilsStub.gatherInputs.returns({
version: 'exe',
Expand Down
59 changes: 57 additions & 2 deletions test/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { randomBytes, randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import { IncomingMessage } from 'node:http';
import * as exec from '@actions/exec';
import * as core from '@actions/core';
Expand Down Expand Up @@ -141,7 +142,7 @@ describe('utils', () => {
coreStub.getBooleanInput.withArgs('install-updates').returns(false);
const res = utils.gatherInputs();
expect(res).to.deep.equal({
version: '2022',
version: '2025',
password: 'secret password',
collation: 'SQL_Latin1_General_CP1_CI_AS',
installArgs: [],
Expand All @@ -164,7 +165,7 @@ describe('utils', () => {
coreStub.getBooleanInput.withArgs('install-updates').returns(false);
const res = utils.gatherInputs();
expect(res).to.deep.equal({
version: '2022',
version: '2025',
password: 'secret password',
collation: 'SQL_Latin1_General_CP1_CI_AS',
installArgs: [],
Expand Down Expand Up @@ -290,6 +291,60 @@ describe('utils', () => {
expect(res).to.match(/^C:\/tools\/[a-f0-9-]*\/setup\.exe$/);
});
});
describe('.downloadSseiInstaller()', () => {
beforeEach('stub deps', () => {
stub(tc, 'downloadTool').callsFake(() => Promise.resolve(`C:/tmp/${randomUUID()}.exe`));
stub(exec, 'exec').resolves(0);
stub(io, 'mv').resolves();
stub(tc, 'cacheDir').callsFake(() => Promise.resolve(`C:/tools/${randomUUID()}`));
stub(crypto, 'generateFileHash').callsFake(() => Promise.resolve(randomBytes(32)));
stub(fs, 'readdir').resolves(['ssei-bootstrapper.exe', 'SQLServer2025-x64-ENU.exe'] as unknown as []);
});
it('returns a path to an exe', async () => {
const res = await utils.downloadSseiInstaller({
sseiUrl: 'https://example.com/ssei.exe',
version: '2025',
});
expect(res).to.match(/^C:\/tools\/[a-f0-9-]*\/setup\.exe$/);
});
it('throws if no sseiUrl', async () => {
try {
await utils.downloadSseiInstaller({
version: '2025',
});
} catch (e) {
expect(e).to.have.property('message', 'No sseiUrl provided');
return;
}
expect.fail('expected to fail');
});
it('throws if no exe found after download', async () => {
(fs.readdir as SinonStub).resolves(['readme.txt', 'data.cab'] as unknown as []);
try {
await utils.downloadSseiInstaller({
sseiUrl: 'https://example.com/ssei.exe',
version: '2025',
});
} catch (e) {
expect(e).to.have.property('message', 'SSEI bootstrapper did not produce an installer exe');
return;
}
expect.fail('expected to fail');
});
it('calculates digests in debug mode', async () => {
coreStub.isDebug.returns(true);
const res = await utils.downloadSseiInstaller({
sseiUrl: 'https://example.com/ssei.exe',
version: '2025',
});
const calls = coreStub.debug.getCalls().filter(({ firstArg }) => {
return firstArg.startsWith('Got SSEI bootstrapper');
});
expect(calls).to.have.lengthOf(1);
expect(calls[0].firstArg).to.match(/^Got SSEI bootstrapper with hash SHA256=/);
expect(res).to.match(/^C:\/tools\/[a-f0-9-]*\/setup\.exe$/);
});
});
describe('.downloadUpdateInstaller()', () => {
let stubClient: SinonStubbedInstance<http.HttpClient>;
let stubResponse: SinonStubbedInstance<http.HttpClientResponse>;
Expand Down
1 change: 1 addition & 0 deletions test/versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ describe('versions', () => {
describe('VERSIONS', () => {
it('exports the supported versions', () => {
assert.deepEqual(Array.from(versions.VERSIONS.keys()), [
'2025',
'2022',
'2019',
'2017',
Expand Down