Skip to content

Commit 8b65bcd

Browse files
fix: fetch cumulative update pages with native fetch to avoid 403s
Since @actions/http-client 3.0.1, requests carry a default user agent of `actions/http-client actions_orchestration_id/<id>` (previously no user-agent header was sent at all). The Microsoft download pages scraped by downloadUpdateInstaller reject requests with that user agent with an instant 403, so cumulative updates were silently skipped and SQL Server was installed as plain RTM. Switch the page scrape to the native fetch API with a browser-like user agent. The actual .exe downloads from download.microsoft.com are unaffected and keep using @actions/tool-cache. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 819cf75 commit 8b65bcd

6 files changed

Lines changed: 44 additions & 28 deletions

File tree

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.

package-lock.json

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@
5454
"@actions/core": "^3.0.1",
5555
"@actions/exec": "^3.0.0",
5656
"@actions/glob": "^0.7.0",
57-
"@actions/http-client": "^4.0.1",
5857
"@actions/io": "^3.0.2",
5958
"@actions/tool-cache": "^4.0.0"
6059
}

src/utils.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { readdir } from 'node:fs/promises';
33
import * as core from '@actions/core';
44
import * as exec from '@actions/exec';
55
import * as glob from '@actions/glob';
6-
import { HttpClient } from '@actions/http-client';
76
import * as io from '@actions/io';
87
import * as tc from '@actions/tool-cache';
98
import { generateFileHash } from './crypto.ts';
@@ -240,10 +239,17 @@ export async function downloadUpdateInstaller(config: VersionConfig): Promise<st
240239
// resolve download url
241240
let downloadLink: string | null = null;
242241
if (!config.updateUrl.endsWith('.exe')) {
243-
const client = new HttpClient();
244-
const res = await client.get(config.updateUrl);
245-
if (res.message.statusCode && res.message.statusCode >= 200 && res.message.statusCode < 300) {
246-
const body = await res.readBody();
242+
// Use native fetch with a browser-like user agent - @actions/http-client >= 3.0.1
243+
// appends "actions_orchestration_id/..." to the user agent, which the
244+
// download page rejects with a 403.
245+
const res = await fetch(config.updateUrl, {
246+
headers: {
247+
'accept': 'text/html,application/xhtml+xml',
248+
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
249+
},
250+
});
251+
if (res.ok) {
252+
const body = await res.text();
247253
const [, link] = body.match(/\s+href\s*=\s*["'](https:\/\/download\.microsoft\.com\/.*\.exe)['"]/) ?? [];
248254
if (link) {
249255
downloadLink = link;
@@ -254,7 +260,7 @@ export async function downloadUpdateInstaller(config: VersionConfig): Promise<st
254260
}
255261
if (!downloadLink) {
256262
core.warning('Unable to download cumulative updates');
257-
core.info(`Response code: ${res.message.statusCode}`);
263+
core.info(`Response code: ${res.status}`);
258264
return '';
259265
}
260266
}

test/utils.ts

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,13 @@ const io = { mv: mock.fn(async () => {}) };
2727
const globCreate = mock.fn(async () => ({ glob: async () => [] as string[] }));
2828
const glob = { create: globCreate };
2929

30-
const httpGet = mock.fn(async () => httpResponse);
31-
const httpResponse = {
32-
message: { statusCode: 200 },
33-
readBody: mock.fn(async () => ''),
30+
const fetchResponse = {
31+
ok: true,
32+
status: 200,
33+
text: mock.fn(async () => ''),
3434
};
35-
class HttpClient {
36-
get = httpGet;
37-
}
38-
const http = { HttpClient };
35+
const fetchMock = mock.fn(async () => fetchResponse);
36+
globalThis.fetch = fetchMock as unknown as typeof fetch;
3937

4038
const readdir = mock.fn(async () => [] as string[]);
4139
const generateFileHash = mock.fn(async () => randomBytes(32));
@@ -45,7 +43,6 @@ mock.module('@actions/exec', { namedExports: exec });
4543
mock.module('@actions/tool-cache', { namedExports: tc });
4644
mock.module('@actions/io', { namedExports: io });
4745
mock.module('@actions/glob', { namedExports: glob });
48-
mock.module('@actions/http-client', { namedExports: http });
4946
mock.module('node:fs/promises', { namedExports: { readdir } });
5047
mock.module('../src/crypto.ts', { namedExports: { generateFileHash } });
5148

@@ -58,7 +55,7 @@ function resetAll() {
5855
core.startGroup, core.endGroup, core.isDebug, core.platform.getDetails,
5956
exec.exec,
6057
tc.downloadTool, tc.cacheFile, tc.cacheDir,
61-
io.mv, globCreate, httpGet, httpResponse.readBody,
58+
io.mv, globCreate, fetchMock, fetchResponse.text,
6259
readdir, generateFileHash,
6360
];
6461
for (const fn of fns) fn.mock.resetCalls();
@@ -72,9 +69,10 @@ function resetAll() {
7269
tc.cacheDir.mock.mockImplementation(async () => `C:/tools/${randomUUID()}`);
7370
io.mv.mock.mockImplementation(async () => {});
7471
globCreate.mock.mockImplementation(async () => ({ glob: async () => [] }));
75-
httpResponse.message = { statusCode: 200 };
76-
httpResponse.readBody.mock.mockImplementation(async () => '');
77-
httpGet.mock.mockImplementation(async () => httpResponse);
72+
fetchResponse.ok = true;
73+
fetchResponse.status = 200;
74+
fetchResponse.text.mock.mockImplementation(async () => '');
75+
fetchMock.mock.mockImplementation(async () => fetchResponse);
7876
readdir.mock.mockImplementation(async () => []);
7977
generateFileHash.mock.mockImplementation(async () => randomBytes(32));
8078
}
@@ -312,8 +310,9 @@ describe('utils', () => {
312310
});
313311
describe('.downloadUpdateInstaller()', () => {
314312
beforeEach(() => {
315-
httpResponse.message = { statusCode: 200 };
316-
httpResponse.readBody.mock.mockImplementation(async () => '<a href="https://download.microsoft.com/update.exe">');
313+
fetchResponse.ok = true;
314+
fetchResponse.status = 200;
315+
fetchResponse.text.mock.mockImplementation(async () => '<a href="https://download.microsoft.com/update.exe">');
317316
});
318317
it('returns a path to an exe', async () => {
319318
const res = await utils.downloadUpdateInstaller({
@@ -349,17 +348,30 @@ describe('utils', () => {
349348
updateUrl: 'https://example.com/sqlupdate.exe',
350349
});
351350
assert.match(res, /^C:\/tools\/[a-f0-9-]*\/sqlupdate\.exe$/);
352-
assert.equal(httpGet.mock.callCount(), 0);
351+
assert.equal(fetchMock.mock.callCount(), 0);
353352
});
354353
it('returns empty string if URL is not resolved', async () => {
355-
httpResponse.readBody.mock.mockImplementation(async () => '<a href="https://example.com/update.exe">');
354+
fetchResponse.text.mock.mockImplementation(async () => '<a href="https://example.com/update.exe">');
356355
const res = await utils.downloadUpdateInstaller({
357356
exeUrl: 'https://example.com/installer.exe',
358357
version: '2022',
359358
updateUrl: 'https://example.com/sqlupdate.html',
360359
});
361360
assert.equal(res, '');
362-
assert.equal(httpGet.mock.callCount(), 1);
361+
assert.equal(fetchMock.mock.callCount(), 1);
362+
});
363+
it('returns empty string if the update page request is rejected', async () => {
364+
fetchResponse.ok = false;
365+
fetchResponse.status = 403;
366+
const res = await utils.downloadUpdateInstaller({
367+
exeUrl: 'https://example.com/installer.exe',
368+
version: '2022',
369+
updateUrl: 'https://example.com/sqlupdate.html',
370+
});
371+
assert.equal(res, '');
372+
const calls = core.info.mock.calls.filter((c) => String(c.arguments[0]).startsWith('Response code'));
373+
assert.equal(calls.length, 1);
374+
assert.equal(String(calls[0].arguments[0]), 'Response code: 403');
363375
});
364376
});
365377
describe('.gatherSummaryFiles()', () => {

0 commit comments

Comments
 (0)