diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cab7162c..be56561b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,11 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] swift: ["5.6.1"] + include: + - os: windows-latest + swift: "5.3" steps: - uses: actions/checkout@v3 - run: npm install diff --git a/__tests__/os.test.ts b/__tests__/os.test.ts index 8a0f1a35..95f139d6 100644 --- a/__tests__/os.test.ts +++ b/__tests__/os.test.ts @@ -19,6 +19,13 @@ describe("os resolver", () => { expect(mac.os).toBe(os.OS.MacOS); expect(mac.version).toBe("latest"); expect(mac.name).toBe("macOS"); + + setSystem({ os: "win32", dist: "Windows", release: "latest" }); + + let windows = await os.getSystem(); + expect(windows.os).toBe(os.OS.Windows); + expect(windows.version).toBe("latest"); + expect(windows.name).toBe("Windows"); }); it("throws an error if the os is not supported", async () => { diff --git a/__tests__/swift-versions.test.ts b/__tests__/swift-versions.test.ts index 56819b67..6822cb80 100644 --- a/__tests__/swift-versions.test.ts +++ b/__tests__/swift-versions.test.ts @@ -3,6 +3,7 @@ import * as versions from "../src/swift-versions"; const macOS: System = { os: OS.MacOS, version: "latest", name: "macOS" }; const ubuntu: System = { os: OS.Ubuntu, version: "latest", name: "Ubuntu" }; +const windows: System = { os: OS.Windows, version: "latest", name: "Windows" }; describe("swift version resolver", () => { it("identifies X.X.X versions", async () => { @@ -39,12 +40,19 @@ describe("swift version resolver", () => { }); it("throws an error if the version isn't available for the system", async () => { - expect.assertions(1); + expect.assertions(2); + try { await versions.verify("5.0.3", macOS); } catch (e) { expect(e).toEqual(new Error('Version "5.0.3" is not available')); } + + try { + await versions.verify("5.2", windows); + } catch (e) { + expect(e).toEqual(new Error('Version "5.2" is not available')); + } }); it("throws an error if version is invalid", async () => { diff --git a/__tests__/visual-studio.test.ts b/__tests__/visual-studio.test.ts new file mode 100644 index 00000000..3dce72c4 --- /dev/null +++ b/__tests__/visual-studio.test.ts @@ -0,0 +1,91 @@ +import os from "os"; +import * as path from "path"; +import * as vs from "../src/visual-studio"; +import { swiftPackage } from "../src/swift-versions"; +import { OS, System } from "../src/os"; + +jest.mock("fs", () => { + const original = jest.requireActual("fs"); + return { + ...original, + existsSync: jest.fn((path) => true), + }; +}); + +const windows: System = { os: OS.Windows, version: "latest", name: "Windows" }; + +describe("visual studio resolver", () => { + const env = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...env }; + }); + + afterEach(() => { + process.env = env; + }); + + it("fetches visual studio requirement for swift version", async () => { + jest.spyOn(os, "release").mockReturnValue("10.0.17763"); + + const req5_3 = vs.vsRequirement(swiftPackage("5.3", windows)); + expect(req5_3.version).toBe("16"); + expect(req5_3.components).toContain( + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64" + ); + expect(req5_3.components).toContain( + "Microsoft.VisualStudio.Component.Windows10SDK.17763" + ); + + const req5_6 = vs.vsRequirement(swiftPackage("5.6", windows)); + expect(req5_6.version).toBe("16"); + expect(req5_6.components).toContain( + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64" + ); + expect(req5_6.components).toContain( + "Microsoft.VisualStudio.Component.Windows10SDK.17763" + ); + }); + + it("adds latest sdk for release newer than or equal to build 17763", async () => { + jest.spyOn(os, "release").mockReturnValue("10.0.17763"); + const req17763 = vs.vsRequirement(swiftPackage("5.3", windows)); + expect(req17763.components).toContain( + "Microsoft.VisualStudio.Component.Windows10SDK.17763" + ); + + jest.spyOn(os, "release").mockReturnValue("10.0.18363"); + const req18363 = vs.vsRequirement(swiftPackage("5.3", windows)); + expect(req18363.components).toContain( + "Microsoft.VisualStudio.Component.Windows10SDK.18363" + ); + }); + + it("adds recommended sdk for release older than build 17763", async () => { + jest.spyOn(os, "release").mockReturnValue("10.0.16299"); + const req16299 = vs.vsRequirement(swiftPackage("5.3", windows)); + expect(req16299.components).toContain( + "Microsoft.VisualStudio.Component.Windows10SDK.17763" + ); + }); + + it("finds vswhere path from environment value", async () => { + const vswherePath = path.join("C:", "bin"); + const vswhereExe = path.join(vswherePath, "vswhere.exe"); + process.env.VSWHERE_PATH = vswherePath; + expect(await vs.getVsWherePath()).toBe(vswhereExe); + }); + + it("finds vswhere path from ProgramFiles environment value", async () => { + const vswhereExe = path.join( + "C:", + "Program Files (x86)", + "Microsoft Visual Studio", + "Installer", + "vswhere.exe" + ); + process.env["ProgramFiles(x86)"] = path.join("C:", "Program Files (x86)"); + expect(await vs.getVsWherePath()).toBe(vswhereExe); + }); +}); diff --git a/src/main.ts b/src/main.ts index a0802ab6..c2ee926c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import * as system from "./os"; import * as versions from "./swift-versions"; import * as macos from "./macos-install"; import * as linux from "./linux-install"; +import * as windows from "./windows-install"; import { getVersion } from "./get-version"; async function run() { @@ -20,6 +21,8 @@ async function run() { case system.OS.Ubuntu: await linux.install(version, platform); break; + case system.OS.Windows: + await windows.install(version, platform); } const current = await getVersion(); diff --git a/src/os.ts b/src/os.ts index 81c531b1..8b55d905 100644 --- a/src/os.ts +++ b/src/os.ts @@ -3,11 +3,19 @@ import getos from "getos"; export enum OS { MacOS, Ubuntu, + Windows, +} + +export namespace OS { + export function all(): OS[] { + return [OS.MacOS, OS.Ubuntu, OS.Windows]; + } } const AVAILABLE_OS: { [platform: string]: string[] } = { macOS: ["latest", "11.0", "10.15"], Ubuntu: ["latest", "20.04", "18.04", "16.04"], + Windows: ["latest", "10"], }; export interface System { @@ -41,6 +49,9 @@ export async function getSystem(): Promise { name: "Ubuntu", }; break; + case "win32": + system = { os: OS.Windows, version: "latest", name: "Windows" }; + break; default: throw new Error(`"${detectedSystem.os}" is not a supported platform`); } diff --git a/src/swift-versions.ts b/src/swift-versions.ts index 2694df47..9ee546c9 100644 --- a/src/swift-versions.ts +++ b/src/swift-versions.ts @@ -3,20 +3,20 @@ import * as core from "@actions/core"; import { System, OS } from "./os"; const VERSIONS_LIST: [string, OS[]][] = [ - ["5.6.1", [OS.MacOS, OS.Ubuntu]], - ["5.6", [OS.MacOS, OS.Ubuntu]], - ["5.5.3", [OS.MacOS, OS.Ubuntu]], - ["5.5.2", [OS.MacOS, OS.Ubuntu]], - ["5.5.1", [OS.MacOS, OS.Ubuntu]], - ["5.5", [OS.MacOS, OS.Ubuntu]], - ["5.4.3", [OS.MacOS, OS.Ubuntu]], - ["5.4.2", [OS.MacOS, OS.Ubuntu]], - ["5.4.1", [OS.MacOS, OS.Ubuntu]], - ["5.4", [OS.MacOS, OS.Ubuntu]], - ["5.3.3", [OS.MacOS, OS.Ubuntu]], - ["5.3.2", [OS.MacOS, OS.Ubuntu]], - ["5.3.1", [OS.MacOS, OS.Ubuntu]], - ["5.3", [OS.MacOS, OS.Ubuntu]], + ["5.6.1", OS.all()], + ["5.6", OS.all()], + ["5.5.3", OS.all()], + ["5.5.2", OS.all()], + ["5.5.1", OS.all()], + ["5.5", OS.all()], + ["5.4.3", OS.all()], + ["5.4.2", OS.all()], + ["5.4.1", OS.all()], + ["5.4", OS.all()], + ["5.3.3", OS.all()], + ["5.3.2", OS.all()], + ["5.3.1", OS.all()], + ["5.3", OS.all()], ["5.2.5", [OS.Ubuntu]], ["5.2.4", [OS.MacOS, OS.Ubuntu]], ["5.2.3", [OS.Ubuntu]], @@ -68,6 +68,7 @@ function notEmpty(value: T | null | undefined): value is T { export interface Package { url: string; name: string; + version: string; } export function swiftPackage(version: string, system: System): Package { @@ -86,6 +87,11 @@ export function swiftPackage(version: string, system: System): Package { archiveName = `swift-${version}-RELEASE-ubuntu${system.version}`; archiveFile = `${archiveName}.tar.gz`; break; + case OS.Windows: + platform = "windows10"; + archiveName = `swift-${version}-RELEASE-windows10.exe`; + archiveFile = archiveName; + break; default: throw new Error("Cannot create download URL for an unsupported platform"); } @@ -93,6 +99,7 @@ export function swiftPackage(version: string, system: System): Package { return { url: `https://swift.org/builds/swift-${version}-release/${platform}/swift-${version}-RELEASE/${archiveFile}`, name: archiveName, + version: version, }; } diff --git a/src/visual-studio.ts b/src/visual-studio.ts new file mode 100644 index 00000000..4d8cb334 --- /dev/null +++ b/src/visual-studio.ts @@ -0,0 +1,162 @@ +import * as os from "os"; +import * as fs from "fs"; +import * as path from "path"; +import * as semver from "semver"; +import * as io from "@actions/io"; +import * as core from "@actions/core"; +import { ExecOptions, exec } from "@actions/exec"; +import { Package } from "./swift-versions"; + +export interface VisualStudio { + installationPath: string; + installationVersion: string; + catalog: VsCatalog; + properties: VsProperties; +} + +export interface VsCatalog { + productDisplayVersion: string; +} + +export interface VsProperties { + setupEngineFilePath: string; +} + +export interface VsRequirement { + version: string; + components: string[]; +} + +/// Setup different version and component requirement +/// based on swift versions if required +export function vsRequirement({ version }: Package): VsRequirement { + const recVersion = "10.0.17763"; + const currentVersion = os.release(); + const useVersion = semver.gte(currentVersion, recVersion) + ? currentVersion + : recVersion; + return { + version: "16", + components: [ + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + `Microsoft.VisualStudio.Component.Windows10SDK.${semver.patch( + useVersion + )}`, + ], + }; +} + +/// Do swift version based additional support files setup +async function setupSupportFiles({ version }: Package, vsInstallPath: string) { + if (semver.lt(version, "5.4.2")) { + /// https://docs.microsoft.com/en-us/cpp/build/building-on-the-command-line?view=msvc-170 + const nativeToolsScriptx86 = path.join( + vsInstallPath, + "VC\\Auxiliary\\Build\\vcvars32.bat" + ); + const copyCommands = [ + 'copy /Y %SDKROOT%\\usr\\share\\ucrt.modulemap "%UniversalCRTSdkDir%\\Include\\%UCRTVersion%\\ucrt\\module.modulemap"', + 'copy /Y %SDKROOT%\\usr\\share\\visualc.modulemap "%VCToolsInstallDir%\\include\\module.modulemap"', + 'copy /Y %SDKROOT%\\usr\\share\\visualc.apinotes "%VCToolsInstallDir%\\include\\visualc.apinotes"', + 'copy /Y %SDKROOT%\\usr\\share\\winsdk.modulemap "%UniversalCRTSdkDir%\\Include\\%UCRTVersion%\\um\\module.modulemap"', + ].join("&&"); + let code = await exec("cmd /k", [nativeToolsScriptx86], { + failOnStdErr: true, + input: Buffer.from(copyCommands, "utf8"), + }); + core.info(`Ran command for swift and exited with code: ${code}`); + } +} + +/// set up required visual studio tools for swift on windows +export async function setupVsTools(pkg: Package) { + /// https://github.com/microsoft/vswhere/wiki/Find-MSBuild + /// get visual studio properties + const vswhereExe = await getVsWherePath(); + const req = vsRequirement(pkg); + const vsWhereExec = + `-products * ` + + `-format json -utf8 ` + + `-latest -version "${req.version}"`; + + let payload = ""; + const options: ExecOptions = {}; + options.listeners = { + stdout: (data: Buffer) => { + payload = payload.concat(data.toString("utf-8")); + }, + stderr: (data: Buffer) => { + core.error(data.toString()); + }, + }; + + // execute the find putting the result of the command in the options vsInstallPath + await exec(`"${vswhereExe}" ${vsWhereExec}`, [], options); + let vs: VisualStudio = JSON.parse(payload)[0]; + if (!vs.installationPath) { + throw new Error( + `Unable to find any visual studio installation for version: ${req.version}.` + ); + } + + /// https://docs.microsoft.com/en-us/visualstudio/install/use-command-line-parameters-to-install-visual-studio?view=vs-2022 + /// install required visual studio components + const vsInstallerExec = + `modify --installPath "${vs.installationPath}"` + + req.components.reduce( + (previous, current) => `${previous} --add "${current}"`, + "" + ) + + ` --quiet`; + + // install required visual studio components + const code = await exec( + `"${vs.properties.setupEngineFilePath}" ${vsInstallerExec}`, + [] + ); + if (code != 0) { + throw new Error( + `Visual Studio installer failed to install required components with exit code: ${code}.` + ); + } + + await setupSupportFiles(pkg, vs.installationPath); +} + +/// Get vswhere and vs_installer paths +/// Borrowed from setup-msbuild action: https://github.com/microsoft/setup-msbuild +/// From source file: https://github.com/microsoft/setup-msbuild/blob/master/src/main.ts +export async function getVsWherePath() { + // check to see if we are using a specific path for vswhere + let vswhereToolExe = ""; + // Env variable for self-hosted runner to provide custom path + const VSWHERE_PATH = process.env.VSWHERE_PATH; + + if (VSWHERE_PATH) { + // specified a path for vswhere, use it + core.debug(`Using given vswhere-path: ${VSWHERE_PATH}`); + vswhereToolExe = path.join(VSWHERE_PATH, "vswhere.exe"); + } else { + // check in PATH to see if it is there + try { + const vsWhereInPath: string = await io.which("vswhere", true); + core.debug(`Found tool in PATH: ${vsWhereInPath}`); + vswhereToolExe = vsWhereInPath; + } catch { + // fall back to VS-installed path + vswhereToolExe = path.join( + process.env["ProgramFiles(x86)"] as string, + "Microsoft Visual Studio", + "Installer", + "vswhere.exe" + ); + core.debug(`Trying Visual Studio-installed path: ${vswhereToolExe}`); + } + } + + if (!fs.existsSync(vswhereToolExe)) { + throw new Error("Action requires the path to where vswhere.exe exists"); + } + + return vswhereToolExe; +} diff --git a/src/windows-install.ts b/src/windows-install.ts new file mode 100644 index 00000000..9e537697 --- /dev/null +++ b/src/windows-install.ts @@ -0,0 +1,90 @@ +import * as os from "os"; +import * as fs from "fs"; +import * as core from "@actions/core"; +import * as toolCache from "@actions/tool-cache"; +import * as path from "path"; +import { ExecOptions, exec } from "@actions/exec"; +import { System } from "./os"; +import { swiftPackage, Package } from "./swift-versions"; +import { setupKeys, verify } from "./gpg"; +import { setupVsTools } from "./visual-studio"; + +export async function install(version: string, system: System) { + if (os.platform() !== "win32") { + core.error("Trying to run windows installer on non-windows os"); + return; + } + + const swiftPkg = swiftPackage(version, system); + let swiftPath = toolCache.find(`swift-${system.name}`, version); + + if (swiftPath === null || swiftPath.trim().length == 0) { + core.debug(`No cached installer found`); + + await setupKeys(); + + let { exe, signature } = await download(swiftPkg); + await verify(signature, exe); + + const exePath = await toolCache.cacheFile( + exe, + swiftPkg.name, + `swift-${system.name}`, + version + ); + + swiftPath = path.join(exePath, swiftPkg.name); + } else { + core.debug("Cached installer found"); + } + + core.debug("Running installer"); + + const options: ExecOptions = {}; + options.listeners = { + stdout: (data: Buffer) => { + core.info(data.toString()); + }, + stderr: (data: Buffer) => { + core.error(data.toString()); + }, + }; + let code = await exec(`"${swiftPath}" -q`, []); + const systemDrive = process.env.SystemDrive ?? "C:"; + const swiftLibPath = path.join(systemDrive, "Library"); + const swiftInstallPath = path.join( + swiftLibPath, + "Developer", + "Toolchains", + "unknown-Asserts-development.xctoolchain", + "usr", + "bin" + ); + + if (code != 0 || !fs.existsSync(swiftInstallPath)) { + throw new Error(`Swift installer failed with exit code: ${code}`); + } + + core.addPath(swiftInstallPath); + + const additionalPaths = [ + path.join(swiftLibPath, "Swift-development", "bin"), + path.join(swiftLibPath, "icu-67", "usr", "bin"), + ]; + additionalPaths.forEach((value, index, array) => core.addPath(value)); + + core.debug(`Swift installed at "${swiftInstallPath}"`); + await setupVsTools(swiftPkg); +} + +async function download({ url, name }: Package) { + core.debug("Downloading Swift for windows"); + + let [exe, signature] = await Promise.all([ + toolCache.downloadTool(url), + toolCache.downloadTool(`${url}.sig`), + ]); + + core.debug("Swift download complete"); + return { exe, signature, name }; +}