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
2 changes: 2 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ The build step (`npm run build`) also runs `npm run docs`, which regenerates the

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

**Tool cache:** Cache and look up downloads only through `src/tool-cache.ts` (`findCachedTool`, `cacheToolDir`, `cacheToolFile`), never `@actions/tool-cache` directly. Its `find()` only does an exact lookup for explicit semver versions, so the wrapper pads versions like `'2022'` to `'2022.0.0'`; a direct `tc.find('sqlserver', '2022')` silently never hits. `test/tool-cache.ts` exercises the real library — keep it unmocked. Cumulative updates are intentionally cached per SQL Server version rather than per CU, so a runner with a persistent tool cache keeps reusing the first CU it downloaded (documented in the README "Caching" section).

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

**ESM module format:** The package is published as ESM (`"type": "module"` in `package.json`). Consequences for contributors:
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ See [action.yml](./action.yml):
Set `sqlserver-version` to `'2025'` (or `'sql-2025'`) to install SQL Server
2025 Standard Developer edition. The default, `'latest'`, also selects 2025.
The action downloads the installation media using Microsoft's SSEI bootstrapper
and attempts to cache the extracted installer for subsequent runs.
and caches the extracted installer for subsequent runs (see [Caching](#caching)).

```yaml
- name: Install SQL Server 2025
Expand All @@ -72,6 +72,16 @@ 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.

### Caching

Downloaded SQL Server installers, cumulative updates, and ODBC and Native
Client installers are stored in the runner's tool cache and reused by later
runs on the same runner for the same version. GitHub-hosted runners start every
job with a fresh tool cache, so this mainly benefits self-hosted runners whose
tool cache persists between jobs. A cached cumulative update is reused until it is removed, so
delete the `sqlupdate` directory from the runner's tool cache to pick up a
newer update.

### Basic usage

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

6 changes: 3 additions & 3 deletions src/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { basename, dirname, join as joinPaths } from 'node:path';
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 { findCachedTool } from './tool-cache.ts';
import { type VersionConfig, VERSIONS } from './versions.ts';
import {
downloadBoxInstaller,
Expand All @@ -24,7 +24,7 @@ import installOdbc from './install-odbc.ts';
* @returns {string} The path to the installer
*/
function findOrDownloadTool(config: VersionConfig): Promise<string> {
const toolPath = tc.find('sqlserver', config.version);
const toolPath = findCachedTool('sqlserver', config.version);
if (toolPath) {
core.info(`Found in cache @ ${toolPath}`);
return Promise.resolve(joinPaths(toolPath, 'setup.exe'));
Expand All @@ -37,7 +37,7 @@ function findOrDownloadTool(config: VersionConfig): Promise<string> {
}

function findOrDownloadUpdates(config: VersionConfig): Promise<string> {
const toolPath = tc.find('sqlupdate', config.version);
const toolPath = findCachedTool('sqlupdate', config.version);
if (toolPath) {
core.info(`Found in cache @ ${toolPath}`);
return Promise.resolve(joinPaths(toolPath, 'sqlupdate.exe'));
Expand Down
7 changes: 4 additions & 3 deletions src/installers/msi-installer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { join as joinPaths } from 'node:path';
import * as core from '@actions/core';
import * as tc from '@actions/tool-cache';
import * as exec from '@actions/exec';
import { cacheToolFile, findCachedTool } from '../tool-cache.ts';
import { Installer, type InstallerConfig } from './installer.ts';

export interface Urls {
Expand Down Expand Up @@ -38,13 +38,14 @@ export class MsiInstaller extends Installer {
}

public async install() {
let path = tc.find(this.name, this.version, this.getArch());
const arch = this.getArch();
let path = findCachedTool(this.name, this.version, arch);
if (path) {
core.info(`Found ${this.name} installer in cache @ ${path}`);
} else {
core.info(`Download ${this.name} installer from ${this.installUrl}`);
path = await this.downloadInstaller(this.installUrl, '.msi').then((tmp) => {
return tc.cacheFile(tmp, `${this.name}.msi`, this.name, this.version);
return cacheToolFile(tmp, `${this.name}.msi`, this.name, this.version, arch);
});
core.info(`Downloaded ${this.name} installer to cache @ ${path}`);
}
Expand Down
62 changes: 62 additions & 0 deletions src/tool-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import * as tc from '@actions/tool-cache';

/**
* Map a version identifier used by this action (e.g. `'2022'` or `'18'`) to the
* explicit semver version it is stored under in the tool cache by padding it to
* three parts (e.g. `'2022.0.0'`).
*
* `@actions/tool-cache` only performs an exact lookup in `find()` for explicit
* semver versions. Anything else is treated as a range and matched against
* cache directories that are named with explicit semver versions, so a tool
* cached as `'2022'` would never be found again.
*
* @param {string} version
* @returns {string}
*/
export function toCacheVersion(version: string): string {
const parts = version.split('.');
while (parts.length < 3) {
parts.push('0');
}
return parts.join('.');
}

/**
* Find a tool previously added to the tool cache with `cacheToolDir()` or
* `cacheToolFile()`.
*
* @param {string} tool
* @param {string} version
* @param {string} [arch]
* @returns {string} The path to the cached tool directory, or an empty string if not found
*/
export function findCachedTool(tool: string, version: string, arch?: string): string {
return tc.find(tool, toCacheVersion(version), arch);
}

/**
* Add a directory to the tool cache.
*
* @param {string} sourceDir
* @param {string} tool
* @param {string} version
* @param {string} [arch]
* @returns {Promise<string>} The path to the cached tool directory
*/
export function cacheToolDir(sourceDir: string, tool: string, version: string, arch?: string): Promise<string> {
return tc.cacheDir(sourceDir, tool, toCacheVersion(version), arch);
}

/**
* Add a file to the tool cache as `targetFile`.
*
* @param {string} sourceFile
* @param {string} targetFile
* @param {string} tool
* @param {string} version
* @param {string} [arch]
* @returns {Promise<string>} The path to the cached tool directory
*/
export function cacheToolFile(sourceFile: string, targetFile: string, tool: string, version: string, arch?: string): Promise<string> {
return tc.cacheFile(sourceFile, targetFile, tool, toCacheVersion(version), arch);
}
7 changes: 4 additions & 3 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as glob from '@actions/glob';
import * as io from '@actions/io';
import * as tc from '@actions/tool-cache';
import { generateFileHash } from './crypto.ts';
import { cacheToolDir, cacheToolFile } from './tool-cache.ts';
import type { VersionConfig } from './versions.ts';

/**
Expand Down Expand Up @@ -160,7 +161,7 @@ async function extractAndCacheInstaller(exePath: string, version: string): Promi
windowsVerbatimArguments: true,
});
core.info('Adding to the cache');
const toolPath = await tc.cacheDir(setupDir, 'sqlserver', version);
const toolPath = await cacheToolDir(setupDir, 'sqlserver', version);
core.debug(`Cached @ ${toolPath}`);
return joinPaths(toolPath, 'setup.exe');
}
Expand Down Expand Up @@ -225,7 +226,7 @@ export async function downloadExeInstaller(config: VersionConfig): Promise<strin
core.debug(`Got setup file (exe) with hash SHA256=${hash.toString('base64')}`);
}
core.info('Adding to the cache');
const toolPath = await tc.cacheFile(exePath, 'setup.exe', 'sqlserver', config.version);
const toolPath = await cacheToolFile(exePath, 'setup.exe', 'sqlserver', config.version);
core.debug(`Cached @ ${toolPath}`);
return joinPaths(toolPath, 'setup.exe');
}
Expand Down Expand Up @@ -342,7 +343,7 @@ export async function downloadUpdateInstaller(config: VersionConfig): Promise<st
core.debug(`Got update file with hash SHA256=${hash.toString('base64')}`);
}
core.info('Adding to the cache');
const toolPath = await tc.cacheFile(updatePath, 'sqlupdate.exe', 'sqlupdate', config.version);
const toolPath = await cacheToolFile(updatePath, 'sqlupdate.exe', 'sqlupdate', config.version);
core.debug(`Cached @ ${toolPath}`);
return joinPaths(toolPath, 'sqlupdate.exe');
}
Expand Down
2 changes: 2 additions & 0 deletions test/install-native-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ describe('install-native-client', () => {
});
});
it('installs from cache', async () => {
Object.defineProperty(process, 'arch', { value: 'x64' });
tc.find.mock.mockImplementation(() => 'C:/tmp/');
await installNativeClient('11');
assert.deepEqual(tc.find.mock.calls[0].arguments, ['sqlncli', '11.0.0', 'x64']);
assert.equal(tc.downloadTool.mock.callCount(), 0);
assert.equal(exec.exec.mock.callCount(), 1);
const call = exec.exec.mock.calls[0];
Expand Down
2 changes: 2 additions & 0 deletions test/install-odbc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ describe('install-odbc', () => {
});
});
it('installs from cache', async () => {
Object.defineProperty(process, 'arch', { value: 'x64' });
tc.find.mock.mockImplementation(() => 'C:/tmp/');
await installOdbc('18');
assert.deepEqual(tc.find.mock.calls[0].arguments, ['msodbcsql', '18.0.0', 'x64']);
assert.equal(tc.downloadTool.mock.callCount(), 0);
assert.equal(exec.exec.mock.callCount(), 1);
assert.deepEqual(exec.exec.mock.calls[0].arguments, [
Expand Down
4 changes: 3 additions & 1 deletion test/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ describe('install', () => {
utils.gatherInputs.mock.mockImplementation(() => defaultInputs({ version: 'ssei' }));
tc.find.mock.mockImplementation(() => 'C:/tool-cache/sqlserver/2025');
await install();
assert.deepEqual(tc.find.mock.calls[0].arguments, ['sqlserver', '2025']);
assert.deepEqual(tc.find.mock.calls[0].arguments, ['sqlserver', '2025.0.0', undefined]);
assert.equal(exec.exec.mock.calls[0].arguments[0], '"C:/tool-cache/sqlserver/2025/setup.exe"');
assert.equal(utils.downloadSseiInstaller.mock.callCount(), 0);
});
Expand All @@ -177,6 +177,8 @@ describe('install', () => {
tc.find.mock.mockImplementation((tool: string) => tool === 'sqlupdate' ? 'C:/tool-cache/sql-update' : '');
utils.gatherInputs.mock.mockImplementation(() => defaultInputs({ version: 'exe', installUpdates: true }));
await install();
assert.deepEqual(tc.find.mock.calls[1].arguments, ['sqlupdate', '2019.0.0', undefined]);
assert.equal(utils.downloadUpdateInstaller.mock.callCount(), 0);
const args = exec.exec.mock.calls[0].arguments[1] as string[];
assert.ok(args.includes('/UPDATEENABLED=1'));
assert.ok(args.includes('/UpdateSource="C:/tool-cache/sql-update"'));
Expand Down
20 changes: 20 additions & 0 deletions test/installers/msi-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,26 @@ describe('MsiInstaller', () => {
});
});
describe('.install()', () => {
let arch: PropertyDescriptor;
beforeEach(() => {
arch = Object.getOwnPropertyDescriptor(process, 'arch')!;
});
afterEach(() => {
Object.defineProperty(process, 'arch', arch);
});
for (const [processArch, installerArch] of [['x64', 'x64'], ['arm64', 'x86']]) {
it(`looks up and caches the installer for the same arch on ${processArch}`, async () => {
Object.defineProperty(process, 'arch', { value: processArch });
const installer = new MsiInstaller({
name: 'test',
version: '18',
urls: { x86: 'https://example.com/x86.msi', x64: 'https://example.com/x64.msi' },
});
await installer.install();
assert.deepEqual(tc.find.mock.calls[0].arguments, ['test', '18.0.0', installerArch]);
assert.deepEqual(tc.cacheFile.mock.calls[0].arguments.slice(1), ['test.msi', 'test', '18.0.0', installerArch]);
});
}
it('returns a cached path if found', async () => {
tc.find.mock.mockImplementation(() => 'C:/cache/test/x86');
const installer = new MsiInstaller({
Expand Down
78 changes: 78 additions & 0 deletions test/tool-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join as joinPaths } from 'node:path';
import { after, before, describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';

// These tests deliberately run against the real @actions/tool-cache rather than a mock: a mocked
// `find()` hid the fact that tools cached under non-semver versions could never be found again.
// Only its logging is silenced.
mock.module('@actions/core', { namedExports: { debug: () => {}, isDebug: () => false } });

const { cacheToolDir, cacheToolFile, findCachedTool, toCacheVersion } = await import('../src/tool-cache.ts');
const { VERSIONS } = await import('../src/versions.ts');

describe('tool-cache', () => {
const env = { RUNNER_TOOL_CACHE: process.env.RUNNER_TOOL_CACHE, RUNNER_TEMP: process.env.RUNNER_TEMP };
let tmp: string;
let sourceDir: string;
let sourceFile: string;
before(async () => {
tmp = await mkdtemp(joinPaths(tmpdir(), 'setup-sqlserver-tool-cache-'));
process.env.RUNNER_TOOL_CACHE = joinPaths(tmp, 'cache');
process.env.RUNNER_TEMP = joinPaths(tmp, 'temp');
sourceDir = joinPaths(tmp, 'source');
sourceFile = joinPaths(tmp, 'download');
await mkdir(sourceDir);
await writeFile(joinPaths(sourceDir, 'setup.exe'), 'setup');
await writeFile(sourceFile, 'download');
});
after(async () => {
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
await rm(tmp, { recursive: true, force: true });
});
describe('.toCacheVersion()', () => {
it('pads versions to an explicit semver version', () => {
assert.equal(toCacheVersion('2022'), '2022.0.0');
assert.equal(toCacheVersion('17.1'), '17.1.0');
});
it('leaves explicit semver versions unchanged', () => {
assert.equal(toCacheVersion('1.0.0'), '1.0.0');
});
});
describe('.cacheToolDir()', () => {
for (const version of VERSIONS.keys()) {
it(`can be found again for SQL Server ${version}`, async () => {
const toolPath = await cacheToolDir(sourceDir, 'sqlserver', version);
assert.equal(findCachedTool('sqlserver', version), toolPath);
assert.equal(await readFile(joinPaths(toolPath, 'setup.exe'), 'utf8'), 'setup');
});
}
});
describe('.cacheToolFile()', () => {
for (const version of VERSIONS.keys()) {
it(`can be found again for SQL Server ${version}`, async () => {
const toolPath = await cacheToolFile(sourceFile, 'sqlupdate.exe', 'sqlupdate', version);
assert.equal(findCachedTool('sqlupdate', version), toolPath);
assert.equal(await readFile(joinPaths(toolPath, 'sqlupdate.exe'), 'utf8'), 'download');
});
}
});
describe('.findCachedTool()', () => {
it('returns an empty string for a version that has not been cached', async () => {
await cacheToolFile(sourceFile, 'tool.exe', 'uncached', '2019');
assert.equal(findCachedTool('uncached', '2022'), '');
});
it('only finds tools cached for the same arch', async () => {
const toolPath = await cacheToolFile(sourceFile, 'tool.exe', 'arch', '18', 'x86');
assert.equal(findCachedTool('arch', '18', 'x86'), toolPath);
assert.equal(findCachedTool('arch', '18', 'x64'), '');
});
});
});
6 changes: 4 additions & 2 deletions test/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ describe('utils', () => {
{ cwd: 'C:/runner temp', windowsVerbatimArguments: true },
]);
assert.deepEqual(tc.cacheDir.mock.calls[0].arguments, [
'C:/runner temp/sqlserver-setup-unique', 'sqlserver', '2022',
'C:/runner temp/sqlserver-setup-unique', 'sqlserver', '2022.0.0', undefined,
]);
});
it('throws if no boxUrl', async () => {
Expand Down Expand Up @@ -281,6 +281,7 @@ describe('utils', () => {
version: '2022',
});
assert.match(res, /^C:\/tools\/[a-f0-9-]*\/setup\.exe$/);
assert.deepEqual(tc.cacheFile.mock.calls[0].arguments.slice(1), ['setup.exe', 'sqlserver', '2022.0.0', undefined]);
});
it('throws if boxUrl', async () => {
await assert.rejects(() => utils.downloadExeInstaller({
Expand Down Expand Up @@ -343,7 +344,7 @@ describe('utils', () => {
{ cwd: 'C:/runner temp/sqlserver-media-unique', windowsVerbatimArguments: true },
]);
assert.deepEqual(tc.cacheDir.mock.calls[0].arguments, [
'C:/runner temp/sqlserver-media-unique/sqlserver-setup-unique', 'sqlserver', '2025',
'C:/runner temp/sqlserver-media-unique/sqlserver-setup-unique', 'sqlserver', '2025.0.0', undefined,
]);
});
it('recognizes uppercase executable extensions', async () => {
Expand Down Expand Up @@ -427,6 +428,7 @@ describe('utils', () => {
updateUrl: 'https://example.com/where-are-updates.html',
});
assert.match(res, /^C:\/tools\/[a-f0-9-]*\/sqlupdate\.exe$/);
assert.deepEqual(tc.cacheFile.mock.calls[0].arguments.slice(1), ['sqlupdate.exe', 'sqlupdate', '2022.0.0', undefined]);
});
it('throws if no update url', async () => {
await assert.rejects(() => utils.downloadUpdateInstaller({
Expand Down