Repository navigation
fix: resolve cumulative update links from new Microsoft download pages #295
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -103,7 +103,14 @@ export default async function install() { | |
| if (!config.updateUrl) { | ||
| core.info('Skipping update installation - version not supported'); | ||
| } else { | ||
| const updatePath = await core.group(`Fetching cumulative updates for ${version}`, () => findOrDownloadUpdates(config)); | ||
| const updatePath = await core.group(`Fetching cumulative updates for ${version}`, async () => { | ||
| try { | ||
| return await findOrDownloadUpdates(config); | ||
| } catch (error) { | ||
| core.warning(`Unable to download cumulative updates; installing without updates. ${error}`); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: |
||
| return ''; | ||
| } | ||
| }); | ||
| if (updatePath) { | ||
| installArgs.push('/UPDATEENABLED=1', `/UpdateSource=${dirname(updatePath)}`); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import { basename, extname, dirname, join as joinPaths } from 'node:path'; | ||
| import { readdir } from 'node:fs/promises'; | ||
| import { setTimeout as delay } from 'node:timers/promises'; | ||
| import * as core from '@actions/core'; | ||
| import * as exec from '@actions/exec'; | ||
| import * as glob from '@actions/glob'; | ||
|
|
@@ -226,8 +227,75 @@ export async function downloadExeInstaller(config: VersionConfig): Promise<strin | |
| return joinPaths(toolPath, 'setup.exe'); | ||
| } | ||
|
|
||
| function isUpdateDownloadUrl(value: unknown): value is string { | ||
| return typeof value === 'string' && /^https:\/\/download\.microsoft\.com\/[^\s"'<>?#]+\.exe$/i.test(value); | ||
| } | ||
|
|
||
| function isRecord(value: unknown): value is Record<string, unknown> { | ||
| return typeof value === 'object' && value !== null && !Array.isArray(value); | ||
| } | ||
|
|
||
| function extractUpdateDownloadUrl(body: string): string { | ||
| const links = new Set<string>(); | ||
| for (const [, script] of body.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi)) { | ||
| const assignment = script.match(/^\s*window\.__DLCDetails__\s*=\s*(\{[\s\S]*\})\s*;?\s*$/); | ||
| if (!assignment) continue; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: the rest of the codebase always uses braces for |
||
| let details: unknown; | ||
| try { | ||
| details = JSON.parse(assignment[1]); | ||
| } catch (error) { | ||
| throw new Error('Invalid cumulative update metadata in Microsoft download page', { cause: error }); | ||
| } | ||
| if (!isRecord(details) || !isRecord(details.dlcDetailsView)) { | ||
| throw new Error('Invalid cumulative update metadata in Microsoft download page'); | ||
| } | ||
| const files = details.dlcDetailsView.downloadFile; | ||
| if (!Array.isArray(files)) { | ||
| throw new Error('Invalid cumulative update file list in Microsoft download page'); | ||
| } | ||
|
Comment on lines
+244
to
+255
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could problems with the metadata fall back to the I'd suggest treating any parse or shape problem as "no usable metadata": log the reason with |
||
| for (const file of files) { | ||
| if (isRecord(file) && isUpdateDownloadUrl(file.url)) links.add(file.url); | ||
| } | ||
| } | ||
| if (!links.size) { | ||
| for (const [, link] of body.matchAll(/<a\b[^>]*?\s+href\s*=\s*["']([^"']+)["'][^>]*>/gi)) { | ||
| if (isUpdateDownloadUrl(link)) links.add(link); | ||
| } | ||
| } | ||
| if (links.size !== 1) { | ||
| throw new Error(links.size | ||
| ? 'Multiple cumulative update installers found in Microsoft download page' | ||
| : 'No HTTPS download.microsoft.com .exe cumulative update installer found in Microsoft download page'); | ||
| } | ||
|
Comment on lines
+265
to
+269
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: the previous implementation called Also, the metadata has a |
||
| return links.values().next().value!; | ||
| } | ||
|
|
||
| async function fetchUpdatePage(url: string): Promise<string> { | ||
| const attempts = 3; | ||
| for (let attempt = 1; ; attempt++) { | ||
| let retryable = false; | ||
| try { | ||
| const res = await fetch(url, { signal: AbortSignal.timeout(30_000) }); | ||
| if (!res.ok) { | ||
| retryable = res.status === 408 || res.status === 429 || res.status >= 500; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The README now says transient download-page failures are retried, but the failures we've actually seen from these pages are 403 and 404 (the tedious runs mentioned in the description). Those turned out to be transient, since the page was back to 200 shortly afterwards.
|
||
| throw new Error(`HTTP ${res.status}`); | ||
| } | ||
| return await res.text(); | ||
| } catch (error) { | ||
| retryable ||= error instanceof TypeError || (error instanceof Error && error.name === 'TimeoutError'); | ||
| const reason = error instanceof Error ? error.message : String(error); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: for network failures, undici's message is just |
||
| if (!retryable || attempt === attempts) { | ||
| throw new Error(`Unable to fetch cumulative update page ${url} after ${attempt} attempt(s): ${reason}`, { cause: error }); | ||
| } | ||
| core.warning(`Cumulative update page fetch failed (${reason}); retrying (${attempt + 1}/${attempts})`); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: |
||
| await delay(1000 * attempt); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Downloads cumulative updates for supported versions. | ||
| * Downloads cumulative updates for supported versions. Throws with the failure | ||
| * reason if a configured update cannot be fetched or resolved. | ||
| * | ||
| * @param {VersionConfig} config | ||
| * @returns {Promise<string>} | ||
|
|
@@ -236,28 +304,11 @@ export async function downloadUpdateInstaller(config: VersionConfig): Promise<st | |
| if (!config.updateUrl) { | ||
| throw new Error('No update url provided'); | ||
| } | ||
| // resolve download url | ||
| let downloadLink: string | null = null; | ||
| if (!config.updateUrl.endsWith('.exe')) { | ||
| const res = await fetch(config.updateUrl); | ||
| if (res.ok) { | ||
| const body = await res.text(); | ||
| const [, link] = body.match(/\s+href\s*=\s*["'](https:\/\/download\.microsoft\.com\/.*\.exe)['"]/) ?? []; | ||
| if (link) { | ||
| downloadLink = link; | ||
| } else { | ||
| core.info('Unable to find download link in body'); | ||
| core.debug(body); | ||
| } | ||
| } | ||
| if (!downloadLink) { | ||
| core.warning('Unable to download cumulative updates'); | ||
| core.info(`Response code: ${res.status}`); | ||
| return ''; | ||
| } | ||
| } | ||
| core.info(`Downloading cumulative update from ${downloadLink ?? config.updateUrl}`); | ||
| const updatePath = await downloadTool(downloadLink ?? config.updateUrl); | ||
| const downloadLink = config.updateUrl.endsWith('.exe') | ||
| ? config.updateUrl | ||
| : extractUpdateDownloadUrl(await fetchUpdatePage(config.updateUrl)); | ||
| core.info(`Downloading cumulative update from ${downloadLink}`); | ||
| const updatePath = await downloadTool(downloadLink); | ||
| if (core.isDebug()) { | ||
| const hash = await generateFileHash(updatePath); | ||
| core.debug(`Got update file with hash SHA256=${hash.toString('base64')}`); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: this paragraph sits between the generated usage block and
### Basic usagewithout a heading. A### Cumulative updatesheading would make it easier to find and link to. (On "tried up to three times", see my comment on the retry status codes insrc/utils.ts.)