From 8e63e3342abd40f34178ce6c58fcb16b52fad9ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Stormacq?= Date: Wed, 6 Nov 2024 23:55:49 +0100 Subject: [PATCH] Add tests for SRP https://github.com/sebsto/xcodeinstall/issues/48 --- .../API/AuthenticationSRPTest.swift | 254 +++++++++++ scripts/ProcessCoverage.swift | 393 ++++++++++++++++++ scripts/RELEASE_DOC.md | 56 --- scripts/build_debug.sh | 13 - scripts/build_fat_binary.sh | 29 -- scripts/{ => deploy}/bootstrap.sh | 0 scripts/{ => deploy}/bottle.sh | 0 scripts/deploy/clean.sh | 12 + scripts/deploy/delete_release.sh | 8 + scripts/{ => deploy}/release_binaries.sh | 0 scripts/{ => deploy}/release_sources.sh | 2 +- scripts/deploy/restoreSession.sh | 2 + scripts/{ => deploy}/version.sh | 0 scripts/deploy/xcodeinstall.rb | 31 ++ scripts/deploy/xcodeinstall.template | 22 + 15 files changed, 723 insertions(+), 99 deletions(-) create mode 100644 Tests/xcodeinstallTests/API/AuthenticationSRPTest.swift create mode 100755 scripts/ProcessCoverage.swift delete mode 100644 scripts/RELEASE_DOC.md delete mode 100755 scripts/build_debug.sh delete mode 100755 scripts/build_fat_binary.sh rename scripts/{ => deploy}/bootstrap.sh (100%) rename scripts/{ => deploy}/bottle.sh (100%) create mode 100755 scripts/deploy/clean.sh create mode 100755 scripts/deploy/delete_release.sh rename scripts/{ => deploy}/release_binaries.sh (100%) rename scripts/{ => deploy}/release_sources.sh (98%) create mode 100755 scripts/deploy/restoreSession.sh rename scripts/{ => deploy}/version.sh (100%) create mode 100644 scripts/deploy/xcodeinstall.rb create mode 100644 scripts/deploy/xcodeinstall.template diff --git a/Tests/xcodeinstallTests/API/AuthenticationSRPTest.swift b/Tests/xcodeinstallTests/API/AuthenticationSRPTest.swift new file mode 100644 index 0000000..d302689 --- /dev/null +++ b/Tests/xcodeinstallTests/API/AuthenticationSRPTest.swift @@ -0,0 +1,254 @@ +// +// AuthenticationTest.swift +// xcodeinstallTests +// +// Created by Stormacq, Sebastien on 21/07/2022. +// + +import XCTest + +@testable import xcodeinstall + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +class AuthenticationSRPTest: HTTPClientTestCase { + + // test authentication returns 401 + func testAuthenticationInvalidUsernamePassword401() async { + + let url = "https://dummy" + + self.sessionData.nextData = getSRPInitResponse() + + self.sessionData.nextResponse = HTTPURLResponse( + url: URL(string: url)!, + statusCode: 401, + httpVersion: nil, + headerFields: getHashcashHeaders() + ) + + do { + let authenticator = getAppleAuthenticator() + authenticator.session = getAppleSession() + + _ = try await authenticator.startAuthentication( + with: .srp, + username: "username", + password: "password" + ) + XCTAssert(false, "No exception thrown") + + } catch AuthenticationError.invalidUsernamePassword { + + } catch { + XCTAssert(false, "Invalid exception thrown \(error)") + } + } + + // test authentication returns 200 + func testAuthentication200() async { + let url = "https://dummy" + var header = [String: String]() + header["Set-Cookie"] = getCookieString() + header["X-Apple-ID-Session-Id"] = "x-apple-id" + header["scnt"] = "scnt" + + self.sessionData.nextData = getSRPInitResponse() + self.sessionData.nextResponse = HTTPURLResponse( + url: URL(string: url)!, + statusCode: 200, + httpVersion: nil, + headerFields: header + ) + + do { + let authenticator = getAppleAuthenticator() + authenticator.session = getAppleSession() + + try await authenticator.startAuthentication( + with: .srp, + username: "username", + password: "password" + ) + + XCTAssertNotNil(authenticator.session) + //XCTAssertNotNil(authenticator.cookies) + + // test apple session + XCTAssertEqual(authenticator.session.scnt, "scnt") + XCTAssertEqual(authenticator.session.xAppleIdSessionId, "x-apple-id") + XCTAssertEqual(authenticator.session.itcServiceKey?.authServiceKey, "key") + XCTAssertEqual(authenticator.session.itcServiceKey?.authServiceUrl, "url") + + // test cookie + //XCTAssertEqual(cookies, getCookieString()) + + } catch { + XCTAssert(false, "Exception thrown : \(error)") + } + } + + // test authentication with No Apple Service Key + func testAuthenticationWithNoAppleServiceKey() async { + let url = "https://dummy" + var header = [String: String]() + header["Set-Cookie"] = getCookieString() + header["X-Apple-ID-Session-Id"] = "x-apple-id" + header["scnt"] = "scnt" + + self.sessionData.nextData = getSRPInitResponse() + self.sessionData.nextResponse = HTTPURLResponse( + url: URL(string: url)!, + statusCode: 200, + httpVersion: nil, + headerFields: header + ) + + do { + let authenticator = getAppleAuthenticator() + authenticator.session = getAppleSession() + authenticator.session.itcServiceKey = nil + + try await authenticator.startAuthentication( + with: .srp, + username: "username", + password: "password" + ) + + XCTAssert(false, "An exception must be thrown)") + + } catch AuthenticationError.unableToRetrieveAppleServiceKey { + // success + } catch { + XCTAssert(false, "Unknown Exception thrown") + } + } + + // test authentication throws an error + func testAuthenticationWithError() async { + let url = "https://dummy" + + self.sessionData.nextData = getSRPInitResponse() + self.sessionData.nextResponse = HTTPURLResponse( + url: URL(string: url)!, + statusCode: 500, + httpVersion: nil, + headerFields: nil + ) + + do { + let authenticator = getAppleAuthenticator() + authenticator.session = getAppleSession() + + _ = try await authenticator.startAuthentication( + with: .srp, + username: "username", + password: "password" + ) + XCTAssert(false, "No exception thrown") + + } catch let error as URLError { + + // verify it returns an error code + XCTAssertNotNil(error) + XCTAssert(error.code == URLError.badServerResponse) + + } catch AuthenticationError.unexpectedHTTPReturnCode(let code) { + + // this is the normal case for this test + XCTAssertEqual(code, 500) + + } catch { + XCTAssert(false, "Invalid exception thrown : \(error)") + } + } + + // test authentication returns 401 + func testAuthenticationInvalidUsernamePassword403() async { + + let url = "https://dummy" + + self.sessionData.nextData = getSRPInitResponse() + self.sessionData.nextResponse = HTTPURLResponse( + url: URL(string: url)!, + statusCode: 403, + httpVersion: nil, + headerFields: nil + ) + + do { + let authenticator = getAppleAuthenticator() + authenticator.session = getAppleSession() + + _ = try await authenticator.startAuthentication( + with: .srp, + username: "username", + password: "password" + ) + XCTAssert(false, "No exception thrown") + + } catch AuthenticationError.invalidUsernamePassword { + + } catch { + XCTAssert(false, "Invalid exception thrown \(error)") + } + + } + + // test authentication returns unhandled http sttaus code + func testAuthenticationUnknownStatusCode() async { + + let url = "https://dummy" + + self.sessionData.nextData = getSRPInitResponse() + self.sessionData.nextResponse = HTTPURLResponse( + url: URL(string: url)!, + statusCode: 100, + httpVersion: nil, + headerFields: nil + ) + + do { + let authenticator = getAppleAuthenticator() + authenticator.session = getAppleSession() + + _ = try await authenticator.startAuthentication( + with: .srp, + username: "username", + password: "password" + ) + XCTAssert(false, "No exception thrown") + + } catch AuthenticationError.unexpectedHTTPReturnCode(let code) { + XCTAssertEqual(code, 100) + } catch { + XCTAssert(false, "Invalid exception thrown \(error)") + } + + } + + private func getSRPInitResponse() -> Data { + """ + { + "iteration" : 1160, + "salt" : "iVGSz0+eXAe5jzBsuSH9Gg==", + "protocol" : "s2k_fo", + "b" : "feF9PcfeU6pKeZb27kxM080eOPvg0wZurW6sGglwhIi63VPyQE1FfU1NKdU5bRHpGYcz23AKetaZWX6EqlIUYsmguN7peY9OU74+V16kvPaMFtSvS4LUrl8W+unt2BTlwRoINTYVgoIiLwXFKAowH6dA9HGaOy8TffKw/FskGK1rPqf8TZJ3IKWk6LA8AAvNhQhaH2/rdtdysJpV+T7eLpoMlcILWCOVL1mzAeTr3lMO4UdcnPokjWIoHIEJXDF8XekRbqSeCZvMlZBP1qSeRFwPuxz//doEk0AS2wU2sZFinPmfz4OV2ESQ4j9lfxE+NvapT+fPAmEUysUL61piMw==", + "c" : "d-74e-7f288e09-93e6-11ef-9a9c-278293010698:PRN" + } + """.data(using: .utf8)! + } + + private func getHashcashHeaders() -> [String: String] { + [ + "X-Apple-HC-Bits": "11", + "X-Apple-HC-Challenge": "4d74fb15eb23f465f1f6fcbf534e5877", + ] + } + private func getCookieString() -> String { + "dslang=GB-EN; Domain=apple.com; Path=/; Secure; HttpOnly, site=GBR; Domain=apple.com; Path=/; Secure; HttpOnly, acn01=tP...QTb; Max-Age=31536000; Expires=Fri, 21-Jul-2023 13:14:09 GMT; Domain=apple.com; Path=/; Secure; HttpOnly, myacinfo=DAWTKN....a47V3; Domain=apple.com; Path=/; Secure; HttpOnly, aasp=DAA5DA...4EAE46; Domain=idmsa.apple.com; Path=/; Secure; HttpOnly" + } + +} diff --git a/scripts/ProcessCoverage.swift b/scripts/ProcessCoverage.swift new file mode 100755 index 0000000..d75f3ea --- /dev/null +++ b/scripts/ProcessCoverage.swift @@ -0,0 +1,393 @@ +#!/usr/bin/swift + +/** +Taken from https://github.com/remko/age-plugin-se/blob/main/Scripts/ProcessCoverage.swift +under MIT License +**/ + +// +// Postprocesses an LLVM coverage report, as output by `swift test --enable-coverage`. +// +// - Filters files out of the report that are not interesting (tests, package +// dependencies) +// - Generates an HTML report of the coverage, with annotated source code +// - Prints a summary report to standard output +// - Generates an SVG of a coverage badge that can be used in the README +// + +import Foundation + +let inputPath = CommandLine.arguments[1] +let outputPath = CommandLine.arguments[2] +let htmlOutputPath = CommandLine.arguments[3] +let badgeOutputPath = CommandLine.arguments[4] + +var report = try JSONDecoder().decode( + CoverageReport.self, + from: try Data(contentsOf: URL(fileURLWithPath: inputPath)) +) + +// Filter out data we don't need +// Ideally, this wouldn't be necessary, and we could specify not to record coverage for +// these files +for di in report.data.indices { + report.data[di].files.removeAll(where: { f in + f.filename.contains("Tests/") || f.filename.contains(".build/") + }) + // Update (some) totals + (report.data[di].totals.lines.covered, report.data[di].totals.lines.count) = + report.data[di].files.reduce( + (0, 0), + { acc, next in + ( + acc.0 + next.summary.lines.covered, + acc.1 + next.summary.lines.count + ) + }) + report.data[di].totals.lines.percent = + 100 * Float(report.data[di].totals.lines.covered) / Float(report.data[di].totals.lines.count) +} + +// Write out filtered report +FileManager.default.createFile( + atPath: outputPath, + contents: try JSONEncoder().encode(report) +) + +//////////////////////////////////////////////////////////////////////////////// +// Summary report +//////////////////////////////////////////////////////////////////////////////// + +var totalCovered = 0 +var totalCount = 0 +print("Code coverage (lines):") +for d in report.data { + for f in d.files { + let filename = f.filename.stripPrefix(FileManager.default.currentDirectoryPath + "/") + let lines = String(format: "%d/%d", f.summary.lines.covered, f.summary.lines.count) + let percent = String( + format: "(%.01f%%)", Float(f.summary.lines.covered * 100) / Float(f.summary.lines.count)) + print( + " \(filename.rightPadded(toLength: 24)) \(lines.leftPadded(toLength: 10)) \(percent.leftPadded(toLength: 8))" + ) + } + totalCovered += d.totals.lines.covered + totalCount += d.totals.lines.count +} +let lines = String(format: "%d/%d", totalCovered, totalCount) +let percent = String( + format: "(%.01f%%)", Float(totalCovered * 100) / Float(totalCount)) +print(" ---") +print( + " \("TOTAL".rightPadded(toLength: 24)) \(lines.leftPadded(toLength: 10)) \(percent.leftPadded(toLength: 8))" +) + +//////////////////////////////////////////////////////////////////////////////// +// Coverage badge +//////////////////////////////////////////////////////////////////////////////// + +let percentRounded = Int((Float(totalCovered * 100) / Float(totalCount)).rounded()) +FileManager.default.createFile( + atPath: badgeOutputPath, + contents: Data( + """ + + Coverage - \(percent)% + + + + + + + + + + + + + + + + + + Coverage + + + + + + + \(percentRounded)% + + + + + """.utf8 + )) + +//////////////////////////////////////////////////////////////////////////////// +// HTML Report +//////////////////////////////////////////////////////////////////////////////// + +var out = "" +var files = "" +var fileID = 0 +for d in report.data { + for f in d.files { + let filename = f.filename.stripPrefix(FileManager.default.currentDirectoryPath + "/") + let percent = String( + format: "%.01f", Float(f.summary.lines.covered * 100) / Float(f.summary.lines.count)) + files += "" + out += "
"
+    var segments = f.segments
+    for (index, line) in try
+      (String(contentsOfFile: f.filename).split(omittingEmptySubsequences: false) { $0.isNewline })
+      .enumerated()
+    {
+      var l = line
+      var columnOffset = 0
+      while let segment = segments.first {
+        if segment.line != index + 1 {
+          break
+        }
+        var endIndex = l.utf8.index(l.startIndex, offsetBy: segment.column - 1 - columnOffset)
+        if endIndex > l.endIndex {
+          endIndex = l.endIndex
+        }
+        columnOffset = segment.column - 1
+        let spanClass = !segment.hasCount ? "" : segment.count > 0 ? "c" : "nc"
+        out +=
+          String(l[l.startIndex.."
+        l = l[endIndex..
+  
+    
+      
+      Coverage
+      
+    
+    
+      
+  """ + out + """
+    
+    
+    """
+FileManager.default.createFile(
+  atPath: htmlOutputPath,
+  contents: Data(out.utf8)
+)
+
+////////////////////////////////////////////////////////////////////////////////
+// LLVM Coverage Export JSON Format
+// See https://github.com/llvm/llvm-project/blob/main/llvm/tools/llvm-cov/CoverageExporterJson.cpp
+////////////////////////////////////////////////////////////////////////////////
+
+struct CoverageReport: Codable {
+  var type: String
+  var version: String
+  var data: [CoverageExport]
+}
+
+struct CoverageExport: Codable {
+  var totals: CoverageSummary
+  var files: [CoverageFile]
+  var functions: [CoverageFunction]
+}
+
+struct CoverageFile: Codable {
+  var filename: String
+  var summary: CoverageSummary
+  var segments: [CoverageSegment]
+  var branches: [CoverageBranch]
+  var expansions: [CoverageExpansion]
+}
+
+struct CoverageFunction: Codable {
+  var count: Int
+  var filenames: [String]
+  var name: String
+  var regions: [CoverageRegion]
+  var branches: [CoverageBranch]
+}
+
+struct CoverageSummary: Codable {
+  var lines: CoverageSummaryEntry
+  var branches: CoverageSummaryEntry
+  var functions: CoverageSummaryEntry
+  var instantiations: CoverageSummaryEntry
+  var regions: CoverageSummaryEntry
+}
+
+struct CoverageSummaryEntry: Codable {
+  var count: Int
+  var covered: Int
+  var percent: Float
+  var notcovered: Int?
+}
+
+struct CoverageSegment {
+  var line: Int
+  var column: Int
+  var count: Int
+  var hasCount: Bool
+  var isRegionEntry: Bool
+  var isGapRegion: Bool
+}
+
+extension CoverageSegment: Decodable {
+  init(from decoder: Decoder) throws {
+    var c = try decoder.unkeyedContainer()
+    line = try c.decode(Int.self)
+    column = try c.decode(Int.self)
+    count = try c.decode(Int.self)
+    hasCount = try c.decode(Bool.self)
+    isRegionEntry = try c.decode(Bool.self)
+    isGapRegion = try c.decode(Bool.self)
+  }
+}
+
+extension CoverageSegment: Encodable {
+  func encode(to encoder: Encoder) throws {
+    var c = encoder.unkeyedContainer()
+    try c.encode(line)
+    try c.encode(column)
+    try c.encode(count)
+    try c.encode(hasCount)
+    try c.encode(isRegionEntry)
+    try c.encode(isGapRegion)
+  }
+}
+
+struct CoverageRegion {
+  var lineStart: Int
+  var columnStart: Int
+  var lineEnd: Int
+  var columnEnd: Int
+  var executionCount: Int
+  var fileID: Int
+  var expandedFileID: Int
+  var regionKind: Int
+}
+
+extension CoverageRegion: Decodable {
+  init(from decoder: Decoder) throws {
+    var c = try decoder.unkeyedContainer()
+    lineStart = try c.decode(Int.self)
+    columnStart = try c.decode(Int.self)
+    lineEnd = try c.decode(Int.self)
+    columnEnd = try c.decode(Int.self)
+    executionCount = try c.decode(Int.self)
+    fileID = try c.decode(Int.self)
+    expandedFileID = try c.decode(Int.self)
+    regionKind = try c.decode(Int.self)
+  }
+}
+
+extension CoverageRegion: Encodable {
+  func encode(to encoder: Encoder) throws {
+    var c = encoder.unkeyedContainer()
+    try c.encode(lineStart)
+    try c.encode(columnStart)
+    try c.encode(lineEnd)
+    try c.encode(columnEnd)
+    try c.encode(executionCount)
+    try c.encode(fileID)
+    try c.encode(expandedFileID)
+    try c.encode(regionKind)
+  }
+}
+
+struct CoverageBranch: Codable {}
+
+struct CoverageExpansion: Codable {}
+
+////////////////////////////////////////////////////////////////////////////////
+// Misc utility
+////////////////////////////////////////////////////////////////////////////////
+
+extension String {
+  func leftPadded(toLength: Int) -> String {
+    if count < toLength {
+      return String(repeating: " ", count: toLength - count) + self
+    } else {
+      return self
+    }
+  }
+
+  func rightPadded(toLength: Int) -> String {
+    if count < toLength {
+      return self + String(repeating: " ", count: toLength - count)
+    } else {
+      return self
+    }
+  }
+
+  func stripPrefix(_ prefix: String) -> String {
+    return self.hasPrefix(prefix) ? String(self.dropFirst(prefix.count)) : self
+  }
+
+  var htmlEscaped: String {
+    return self.replacingOccurrences(of: "&", with: "&").replacingOccurrences(
+      of: "<", with: "<"
+    ).replacingOccurrences(
+      of: ">", with: ">")
+  }
+}
\ No newline at end of file
diff --git a/scripts/RELEASE_DOC.md b/scripts/RELEASE_DOC.md
deleted file mode 100644
index 94adc4e..0000000
--- a/scripts/RELEASE_DOC.md
+++ /dev/null
@@ -1,56 +0,0 @@
-## TODO 
-
-Consider using a github action for this 
-https://github.com/Homebrew/actions
-https://github.com/ipatch/homebrew-freecad-pg13/blob/main/.github/workflows/publish.yml
-https://github.com/marketplace/actions/homebrew-bump-cask
-
-## To release a new version.
-
-1. Commit all other changes and push them
-
-2. Update version number in `scripts/release-sources.sh`
-
-3. `./scripts/release_sources.sh`
-
-This script 
-- creates a new version 
-- tags the branch and push the tag 
-- creates a GitHub release 
-- creates a brew formula with the new release
-
-4. `./scripts/bottle.sh` 
-
-This script
-- creates the brew bottles TAR file and the code to add to the formula 
-
-5. `./scripts/release_binaries.sh` 
-
-This script 
-- uploads the bottles to the GitHub Release
-- update the brew formula with the bootle definition 
-
-## To undo a release 
-
-While testing this procedure, it is useful to undo a release.
-
-!! Destructive actions !! 
-
-1. `./scripts/delete_release.sh`  
-
-2. `git reset HEAD~1`
-
-3. Reset Version file 
-
-```zsh
-SOURCE_FILE="Sources/xcodeinstall/Version.swift"
-
-cat <"$SOURCE_FILE"
-// Generated by: scripts/version
-enum Version {
-    static let version = ""
-}
-EOF
-```
-
-4. `rm -rf ~/Library/Caches/Homebrew/downloads/*xcodeinstall-*.tar.gz`  
\ No newline at end of file
diff --git a/scripts/build_debug.sh b/scripts/build_debug.sh
deleted file mode 100755
index db7aee9..0000000
--- a/scripts/build_debug.sh
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/bin/sh -e
-
-echo "๐Ÿ— Building a debug build for current machine architecture : $(uname -m)"
-swift build --configuration debug
-
-echo "๐Ÿงช Running unit tests"
-swift test > test.log
-
-if [ $? -eq 0 ]; then
-    echo "โœ… OK"
-else
-    echo "๐Ÿ›‘ Test failed, check test.log for details"
-fi 
\ No newline at end of file
diff --git a/scripts/build_fat_binary.sh b/scripts/build_fat_binary.sh
deleted file mode 100755
index 03018f1..0000000
--- a/scripts/build_fat_binary.sh
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/bin/sh
-set -e
-set -o pipefail
-
-echo "\nโž• Get version number \n"
-if [ ! -f VERSION ]; then 
-    echo "VERSION file does not exist."
-    echo "It is created by 'scripts/release_sources.sh"
-    exit -1
-fi
-VERSION=$(cat VERSION)
-
-mkdir -p dist/fat
-
-echo "\n๐Ÿ“ฆ Downloading packages according to Package.resolved\n"
-swift package --disable-sandbox resolve
-
-# echo "\n๐Ÿฉน Patching Switft Tools Support Core dependency to produce a static library\n"
-# sed -i .bak -E -e "s/^( *type: .dynamic,)$/\/\/\1/" .build/checkouts/swift-tools-support-core/Package.swift
-
-echo "\n๐Ÿ— Building the fat binary (x86_64 and arm64) version\n"
-swift build --configuration release \
-            --arch arm64            \
-            --arch x86_64           \
-            --disable-sandbox
-cp .build/apple/Products/Release/xcodeinstall dist/fat
-
-
-
diff --git a/scripts/bootstrap.sh b/scripts/deploy/bootstrap.sh
similarity index 100%
rename from scripts/bootstrap.sh
rename to scripts/deploy/bootstrap.sh
diff --git a/scripts/bottle.sh b/scripts/deploy/bottle.sh
similarity index 100%
rename from scripts/bottle.sh
rename to scripts/deploy/bottle.sh
diff --git a/scripts/deploy/clean.sh b/scripts/deploy/clean.sh
new file mode 100755
index 0000000..34d358f
--- /dev/null
+++ b/scripts/deploy/clean.sh
@@ -0,0 +1,12 @@
+#!/bin/sh -e
+#
+# script/clean
+#
+# Deletes the build directory.
+#
+
+echo "๐Ÿงป Cleaning build artefacts"
+swift package clean
+swift package reset
+rm -rf dist/*
+rm -rf ~/Library/Caches/Homebrew/downloads/*xcodeinstall*.tar.gz
\ No newline at end of file
diff --git a/scripts/deploy/delete_release.sh b/scripts/deploy/delete_release.sh
new file mode 100755
index 0000000..3953963
--- /dev/null
+++ b/scripts/deploy/delete_release.sh
@@ -0,0 +1,8 @@
+#!/bin/sh -e
+
+VERSION_TO_DELETE=$(cat VERSION)
+TAG=v$VERSION_TO_DELETE
+
+gh release delete $TAG
+git tag -d $TAG
+git push origin --delete $TAG
\ No newline at end of file
diff --git a/scripts/release_binaries.sh b/scripts/deploy/release_binaries.sh
similarity index 100%
rename from scripts/release_binaries.sh
rename to scripts/deploy/release_binaries.sh
diff --git a/scripts/release_sources.sh b/scripts/deploy/release_sources.sh
similarity index 98%
rename from scripts/release_sources.sh
rename to scripts/deploy/release_sources.sh
index afd9f8f..22a903e 100755
--- a/scripts/release_sources.sh
+++ b/scripts/deploy/release_sources.sh
@@ -5,7 +5,7 @@ set -o pipefail
 # echo "Did you increment version number before running this script ?"
 # exit -1 
 ######################
-VERSION="0.9.1"
+VERSION="0.10.1"
 ######################
 
 echo $VERSION > VERSION
diff --git a/scripts/deploy/restoreSession.sh b/scripts/deploy/restoreSession.sh
new file mode 100755
index 0000000..52ed8d4
--- /dev/null
+++ b/scripts/deploy/restoreSession.sh
@@ -0,0 +1,2 @@
+cp ~/.xcodeinstall/cookies.seb ~/.xcodeinstall/cookies
+cp ~/.xcodeinstall/session.seb ~/.xcodeinstall/session
diff --git a/scripts/version.sh b/scripts/deploy/version.sh
similarity index 100%
rename from scripts/version.sh
rename to scripts/deploy/version.sh
diff --git a/scripts/deploy/xcodeinstall.rb b/scripts/deploy/xcodeinstall.rb
new file mode 100644
index 0000000..563c27e
--- /dev/null
+++ b/scripts/deploy/xcodeinstall.rb
@@ -0,0 +1,31 @@
+# Generated by following instructions at
+# https://betterprogramming.pub/a-step-by-step-guide-to-create-homebrew-taps-from-github-repos-f33d3755ba74
+# https://medium.com/@mxcl/maintaining-a-homebrew-tap-for-swift-projects-7287ed379324
+
+class Xcodeinstall < Formula
+  desc "This is a command-line tool to download and install Apple's Xcode"
+  homepage "https://github.com/sebsto/xcodeinstall"
+  url "https://github.com/sebsto/xcodeinstall/archive/refs/tags/v0.10.1.tar.gz"
+  sha256 "0e012e6b0f22e39f11a786accd42927a7f90253adc717bf30dee28143af6011e"
+  license "Apache-2.0"
+
+  # insert bottle definition here
+  bottle do
+    root_url "https://github.com/sebsto/xcodeinstall/releases/download/v0.10.1"
+    sha256 cellar: :any_skip_relocation, arm64_ventura: "82b346eee91fadd6868036f4a5631088c5e4d159788f6474ac15903d2612b34f"
+    sha256 cellar: :any_skip_relocation, ventura: "82b346eee91fadd6868036f4a5631088c5e4d159788f6474ac15903d2612b34f"
+    sha256 cellar: :any_skip_relocation, arm64_sonoma: "82b346eee91fadd6868036f4a5631088c5e4d159788f6474ac15903d2612b34f"
+    sha256 cellar: :any_skip_relocation, sonoma: "82b346eee91fadd6868036f4a5631088c5e4d159788f6474ac15903d2612b34f"
+    sha256 cellar: :any_skip_relocation, arm64_sequoia: "82b346eee91fadd6868036f4a5631088c5e4d159788f6474ac15903d2612b34f"
+    sha256 cellar: :any_skip_relocation, sequoia: "82b346eee91fadd6868036f4a5631088c5e4d159788f6474ac15903d2612b34f"
+  end
+
+  def install
+    system "./scripts/build_fat_binary.sh"
+    bin.install ".build/apple/Products/Release/xcodeinstall"
+  end
+
+  test do
+    assert_equal version.to_s, shell_output("#{bin}/xcodeinstall --version").chomp
+  end
+end
diff --git a/scripts/deploy/xcodeinstall.template b/scripts/deploy/xcodeinstall.template
new file mode 100644
index 0000000..7bd9e94
--- /dev/null
+++ b/scripts/deploy/xcodeinstall.template
@@ -0,0 +1,22 @@
+# Generated by following instructions at
+# https://betterprogramming.pub/a-step-by-step-guide-to-create-homebrew-taps-from-github-repos-f33d3755ba74
+# https://medium.com/@mxcl/maintaining-a-homebrew-tap-for-swift-projects-7287ed379324
+
+class Xcodeinstall < Formula
+  desc "This is a command-line tool to download and install Apple's Xcode"
+  homepage "https://github.com/sebsto/xcodeinstall"
+  URL
+  SHA
+  license "Apache-2.0"
+
+  # insert bottle definition here
+
+  def install
+    system "./scripts/build_fat_binary.sh"
+    bin.install ".build/apple/Products/Release/xcodeinstall"
+  end
+
+  test do
+    assert_equal version.to_s, shell_output("#{bin}/xcodeinstall --version").chomp
+  end
+end