diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 22b18830..65fc7375 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -94,6 +94,26 @@ jobs: - name: Extract and Run Workflow Tests run: cp swiftly-*.tar.gz /root/swiftly.tar.gz && cp test-swiftly-*.tar.gz /root && cd /root && tar zxf test-swiftly-*.tar.gz && ./test-swiftly -y ./swiftly.tar.gz + release-custom-install-test: + name: Test Release - Custom Install Location + needs: releasebuildcheck + runs-on: ubuntu-latest + container: + image: "ubuntu:24.04" + steps: + - name: Prepare System + run: apt-get update && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt-get -y install ca-certificates gpg tzdata + - name: Download Release + uses: actions/download-artifact@v4 + with: + name: swiftly-release-x86_64 + - name: Download Tests + uses: actions/download-artifact@v4 + with: + name: swiftly-tests-x86_64 + - name: Extract and Run Workflow Tests + run: cp swiftly-*.tar.gz /root/swiftly.tar.gz && cp test-swiftly-*.tar.gz /root && cd /root && tar zxf test-swiftly-*.tar.gz && ./test-swiftly -y --custom-location ./swiftly.tar.gz + formatcheck: name: Format Check runs-on: ubuntu-latest diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index 682bc23b..db284ad6 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -35,7 +35,9 @@ public struct Linux: Platform { } public func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> URL { - self.swiftlyHomeDir(ctx).appendingPathComponent("toolchains", isDirectory: true) + ctx.mockedHomeDir.map { $0.appendingPathComponent("toolchains", isDirectory: true) } + ?? ProcessInfo.processInfo.environment["SWIFTLY_TOOLCHAINS_DIR"].map { URL(fileURLWithPath: $0) } + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/share/swiftly/toolchains") } public var toolchainFileExtension: String { diff --git a/Sources/MacOSPlatform/MacOS.swift b/Sources/MacOSPlatform/MacOS.swift index d6b73825..3dce5795 100644 --- a/Sources/MacOSPlatform/MacOS.swift +++ b/Sources/MacOSPlatform/MacOS.swift @@ -18,6 +18,11 @@ public struct MacOS: Platform { .appendingPathComponent(".swiftly", isDirectory: true) } + public var defaultToolchainsDirectory: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Developer/Toolchains", isDirectory: true) + } + public func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> URL { ctx.mockedHomeDir.map { $0.appendingPathComponent("bin", isDirectory: true) } ?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { URL(fileURLWithPath: $0) } @@ -27,10 +32,9 @@ public struct MacOS: Platform { public func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> URL { ctx.mockedHomeDir.map { $0.appendingPathComponent("Toolchains", isDirectory: true) } - // The toolchains are always installed here by the installer. We bypass the installer in the case of test mocks - ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent( - "Library/Developer/Toolchains", isDirectory: true - ) + ?? ProcessInfo.processInfo.environment["SWIFTLY_TOOLCHAINS_DIR"].map { URL(fileURLWithPath: $0) } + // This is where the installer will put the toolchains, and where Xcode can find them + ?? self.defaultToolchainsDirectory } public var toolchainFileExtension: String { @@ -56,31 +60,47 @@ public struct MacOS: Platform { throw SwiftlyError(message: "\(tmpFile) doesn't exist") } - if !self.swiftlyToolchainsDir(ctx).fileExists() { + let toolchainsDir = self.swiftlyToolchainsDir(ctx) + + if !toolchainsDir.fileExists() { try FileManager.default.createDirectory( - at: self.swiftlyToolchainsDir(ctx), withIntermediateDirectories: false + at: self.swiftlyToolchainsDir(ctx), withIntermediateDirectories: true ) } - if ctx.mockedHomeDir == nil { + if toolchainsDir == self.defaultToolchainsDirectory { + // If the toolchains go into the default user location then we use the installer to install them await ctx.print("Installing package in user home directory...") try runProgram( "installer", "-verbose", "-pkg", tmpFile.path, "-target", "CurrentUserHomeDirectory", quiet: !verbose ) } else { - // In the case of a mock for testing purposes we won't use the installer, perferring a manual process because - // the installer will not install to an arbitrary path, only a volume or user home directory. + // Otherwise, we extract the pkg into the requested toolchains directory. await ctx.print("Expanding pkg...") let tmpDir = self.getTempFilePath() - let toolchainDir = self.swiftlyToolchainsDir(ctx).appendingPathComponent( + let toolchainDir = toolchainsDir.appendingPathComponent( "\(version.identifier).xctoolchain", isDirectory: true ) + if !toolchainDir.fileExists() { try FileManager.default.createDirectory( at: toolchainDir, withIntermediateDirectories: false ) } + + await ctx.print("Checking package signature...") + do { + try runProgram("pkgutil", "--check-signature", tmpFile.path, quiet: !verbose) + } catch { + // If this is not a test that uses mocked toolchains then we must throw this error and abort installation + guard ctx.mockedHomeDir != nil else { + throw error + } + + // We permit the signature verification to fail during testing + await ctx.print("Signature verification failed, which is allowable during testing with mocked toolchains") + } try runProgram("pkgutil", "--verbose", "--expand", tmpFile.path, tmpDir.path, quiet: !verbose) // There's a slight difference in the location of the special Payload file between official swift packages // and the ones that are mocked here in the test framework. diff --git a/Sources/Swiftly/Init.swift b/Sources/Swiftly/Init.swift index 6afc047b..8c4ce8b3 100644 --- a/Sources/Swiftly/Init.swift +++ b/Sources/Swiftly/Init.swift @@ -70,6 +70,8 @@ struct Init: SwiftlyCommand { // Give the user the prompt and the choice to abort at this point. if !assumeYes { + let toolchainsDir = Swiftly.currentPlatform.swiftlyToolchainsDir(ctx) + var msg = """ Welcome to swiftly, the Swift toolchain manager for Linux and macOS! @@ -81,12 +83,20 @@ struct Init: SwiftlyCommand { \(Swiftly.currentPlatform.swiftlyHomeDir(ctx).path) - Directory for configuration files \(Swiftly.currentPlatform.swiftlyBinDir(ctx).path) - Links to the binaries of the active toolchain - \(Swiftly.currentPlatform.swiftlyToolchainsDir(ctx).path) - Directory hosting installed toolchains + \(toolchainsDir.path) - Directory hosting installed toolchains These locations can be changed by setting the environment variables - SWIFTLY_HOME_DIR and SWIFTLY_BIN_DIR before running 'swiftly init' again. + SWIFTLY_HOME_DIR, SWIFTLY_BIN_DIR, and SWIFTLY_TOOLCHAINS_DIR before running 'swiftly init' again. """ +#if os(macOS) + if toolchainsDir != FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Developer/Toolchains", isDirectory: true) { + msg += """ + + NOTE: The toolchains are not being installed in a standard macOS location, so Xcode may not be able to find them. + """ + } +#endif if !skipInstall { msg += """ @@ -186,6 +196,7 @@ struct Init: SwiftlyCommand { env = """ set -x SWIFTLY_HOME_DIR "\(Swiftly.currentPlatform.swiftlyHomeDir(ctx).path)" set -x SWIFTLY_BIN_DIR "\(Swiftly.currentPlatform.swiftlyBinDir(ctx).path)" + set -x SWIFTLY_TOOLCHAINS_DIR "\(Swiftly.currentPlatform.swiftlyToolchainsDir(ctx).path)" if not contains "$SWIFTLY_BIN_DIR" $PATH set -x PATH "$SWIFTLY_BIN_DIR" $PATH end @@ -195,6 +206,7 @@ struct Init: SwiftlyCommand { env = """ export SWIFTLY_HOME_DIR="\(Swiftly.currentPlatform.swiftlyHomeDir(ctx).path)" export SWIFTLY_BIN_DIR="\(Swiftly.currentPlatform.swiftlyBinDir(ctx).path)" + export SWIFTLY_TOOLCHAINS_DIR="\(Swiftly.currentPlatform.swiftlyToolchainsDir(ctx).path)" if [[ ":$PATH:" != *":$SWIFTLY_BIN_DIR:"* ]]; then export PATH="$SWIFTLY_BIN_DIR:$PATH" fi @@ -250,50 +262,50 @@ struct Init: SwiftlyCommand { addEnvToProfile = true } - var postInstall: String? - var pathChanged = false - - if !skipInstall { - let latestVersion = try await Install.resolve(ctx, config: config, selector: ToolchainSelector.latest) - (postInstall, pathChanged) = try await Install.execute(ctx, version: latestVersion, &config, useInstalledToolchain: true, verifySignature: true, verbose: verbose, assumeYes: assumeYes) - } - if addEnvToProfile { try Data(sourceLine.utf8).append(to: profileHome) + } + } - if !quietShellFollowup { - await ctx.print(""" - To begin using installed swiftly from your current shell, first run the following command: - \(sourceLine) + var postInstall: String? + var pathChanged = false - """) - } - } + if !skipInstall { + let latestVersion = try await Install.resolve(ctx, config: config, selector: ToolchainSelector.latest) + (postInstall, pathChanged) = try await Install.execute(ctx, version: latestVersion, &config, useInstalledToolchain: true, verifySignature: true, verbose: verbose, assumeYes: assumeYes) + } + + if !quietShellFollowup { + await ctx.print(""" + To begin using installed swiftly from your current shell, first run the following command: + \(sourceLine) - // Fish doesn't have path caching, so this might only be needed for bash/zsh - if pathChanged && !quietShellFollowup && !shell.hasSuffix("fish") { - await ctx.print(""" - Your shell caches items on your path for better performance. Swiftly has added - items to your path that may not get picked up right away. You can update your - shell's environment by running + """) + } - hash -r + // Fish doesn't have path caching, so this might only be needed for bash/zsh + if pathChanged && !quietShellFollowup && !shell.hasSuffix("fish") { + await ctx.print(""" + Your shell caches items on your path for better performance. Swiftly has added + items to your path that may not get picked up right away. You can update your + shell's environment by running - or restarting your shell. + hash -r - """) - } + or restarting your shell. - if let postInstall { - await ctx.print(""" - There are some dependencies that should be installed before using this toolchain. - You can run the following script as the system administrator (e.g. root) to prepare - your system: + """) + } - \(postInstall) + if let postInstall { + await ctx.print(""" + There are some dependencies that should be installed before using this toolchain. + You can run the following script as the system administrator (e.g. root) to prepare + your system: - """) - } + \(postInstall) + + """) } } } diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index 4d6db213..1d1df3c5 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -171,7 +171,7 @@ extension Platform { guard tcPath.fileExists() else { throw SwiftlyError( message: - "Toolchain \(toolchain) could not be located. You can try `swiftly uninstall \(toolchain)` to uninstall it and then `swiftly install \(toolchain)` to install it again." + "Toolchain \(toolchain) could not be located in \(tcPath). You can try `swiftly uninstall \(toolchain)` to uninstall it and then `swiftly install \(toolchain)` to install it again." ) } diff --git a/Sources/TestSwiftly/TestSwiftly.swift b/Sources/TestSwiftly/TestSwiftly.swift index fa967aa9..e8efa4e3 100644 --- a/Sources/TestSwiftly/TestSwiftly.swift +++ b/Sources/TestSwiftly/TestSwiftly.swift @@ -21,6 +21,9 @@ struct TestSwiftly: AsyncParsableCommand { @Flag(name: [.customShort("y"), .long], help: "Disable confirmation prompts by assuming 'yes'") var assumeYes: Bool = false + @Flag(help: "Install swiftly to a custom location, not added to the user profile.") + var customLocation: Bool = false + @Argument var swiftlyArchive: String? = nil mutating func run() async throws { @@ -41,30 +44,40 @@ struct TestSwiftly: AsyncParsableCommand { try currentPlatform.runProgram("installer", "-pkg", swiftlyArchive, "-target", "CurrentUserHomeDirectory", quiet: false) #endif - print("Running 'swiftly init --assume-yes --verbose' to install swiftly and the latest toolchain") - #if os(Linux) let extractedSwiftly = "./swiftly" #elseif os(macOS) let extractedSwiftly = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".swiftly/bin/swiftly").path #endif - try currentPlatform.runProgram(extractedSwiftly, "init", "--assume-yes", "--skip-install", quiet: false) - + var env = ProcessInfo.processInfo.environment let shell = try await currentPlatform.getShell() + var customLoc: URL? - var env = ProcessInfo.processInfo.environment + if self.customLocation { + customLoc = currentPlatform.getTempFilePath() - // Setting this environment helps to ensure that the profile gets sourced with bash, even if it is not in an interactive shell - if shell.hasSuffix("bash") { - env["BASH_ENV"] = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".profile").path - } else if shell.hasSuffix("zsh") { - env["ZDOTDIR"] = FileManager.default.homeDirectoryForCurrentUser.path - } else if shell.hasSuffix("fish") { - env["XDG_CONFIG_HOME"] = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config").path - } + print("Installing swiftly to custom location \(customLoc!.path)") + env["SWIFTLY_HOME_DIR"] = customLoc!.path + env["SWIFTLY_BIN_DIR"] = customLoc!.appendingPathComponent("bin").path + env["SWIFTLY_TOOLCHAINS_DIR"] = customLoc!.appendingPathComponent("toolchains").path - try currentPlatform.runProgram(shell, "-l", "-c", "swiftly install --assume-yes latest --post-install-file=./post-install.sh", quiet: false, env: env) + try currentPlatform.runProgram(extractedSwiftly, "init", "--assume-yes", "--no-modify-profile", "--skip-install", quiet: false, env: env) + try currentPlatform.runProgram(shell, "-l", "-c", ". \"\(customLoc!.path)/env.sh\" && swiftly install --assume-yes latest --post-install-file=./post-install.sh", quiet: false, env: env) + } else { + print("Installing swiftly to the default location.") + // Setting this environment helps to ensure that the profile gets sourced with bash, even if it is not in an interactive shell + if shell.hasSuffix("bash") { + env["BASH_ENV"] = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".profile").path + } else if shell.hasSuffix("zsh") { + env["ZDOTDIR"] = FileManager.default.homeDirectoryForCurrentUser.path + } else if shell.hasSuffix("fish") { + env["XDG_CONFIG_HOME"] = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config").path + } + + try currentPlatform.runProgram(extractedSwiftly, "init", "--assume-yes", "--skip-install", quiet: false, env: env) + try currentPlatform.runProgram(shell, "-l", "-c", "swiftly install --assume-yes latest --post-install-file=./post-install.sh", quiet: false, env: env) + } var swiftReady = false @@ -77,7 +90,9 @@ struct TestSwiftly: AsyncParsableCommand { swiftReady = true } - if swiftReady { + if let customLoc = customLoc, swiftReady { + try currentPlatform.runProgram(shell, "-l", "-c", ". \"\(customLoc.path)/env.sh\" && swift --version", quiet: false, env: env) + } else if swiftReady { try currentPlatform.runProgram(shell, "-l", "-c", "swift --version", quiet: false, env: env) } }