├── .eslintrc.json ├── .github └── workflows │ ├── e2e.yml │ └── workflow.yml ├── .gitignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── __tests__ ├── data │ ├── xcode-beta-license.plist │ ├── xcode-empty-license.plist │ ├── xcode-stable-license.plist │ └── xcode-version.plist ├── xcode-selector.test.ts └── xcode-utils.test.ts ├── action.yml ├── dist └── index.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── setup-xcode.ts ├── xcode-selector.ts └── xcode-utils.ts ├── tsconfig.eslint.json └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es6": true, 5 | "jest/globals": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:jest/recommended", 12 | "prettier" 13 | ], 14 | "parser": "@typescript-eslint/parser", 15 | "parserOptions": { 16 | "project": "./tsconfig.eslint.json", 17 | "ecmaVersion": 2018, 18 | "sourceType": "module" 19 | }, 20 | "plugins": ["@typescript-eslint", "jest"], 21 | "ignorePatterns": ["node_modules/"] 22 | } -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: Validate 'setup-xcode' 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | schedule: 8 | - cron: 0 0 * * * 9 | 10 | jobs: 11 | versions-macOS-12: 12 | name: macOS 12 13 | runs-on: macos-12 14 | strategy: 15 | matrix: 16 | xcode-version: ['13.2.1', '13.4', '14.0.1', '14', '14.2', latest, latest-stable] 17 | fail-fast: false 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | 22 | - uses: ./ 23 | name: Setup Xcode 24 | id: setup-xcode 25 | with: 26 | xcode-version: ${{ matrix.xcode-version }} 27 | - name: Print output variables 28 | run: | 29 | echo "Version: ${{ steps.setup-xcode.outputs.version }}" 30 | echo "Path: ${{ steps.setup-xcode.outputs.path }}" 31 | 32 | versions-macOS-13: 33 | name: macOS 13 34 | runs-on: macos-13 35 | strategy: 36 | matrix: 37 | xcode-version: ['14', '14.2', '14.3.1', latest, latest-stable] 38 | fail-fast: false 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@v4 42 | 43 | - uses: ./ 44 | name: Setup Xcode 45 | id: setup-xcode 46 | with: 47 | xcode-version: ${{ matrix.xcode-version }} 48 | - name: Print output variables 49 | run: | 50 | echo "Version: ${{ steps.setup-xcode.outputs.version }}" 51 | echo "Path: ${{ steps.setup-xcode.outputs.path }}" 52 | 53 | versions-macOS-14: 54 | name: macOS 14 55 | runs-on: macos-14 56 | strategy: 57 | matrix: 58 | # https://github.com/actions/runner-images/blob/main/images/macos/macos-14-Readme.md#xcode 59 | xcode-version: ['14.3.1', '15.2', '15.3', latest, latest-stable] 60 | fail-fast: false 61 | steps: 62 | - name: Checkout 63 | uses: actions/checkout@v4 64 | 65 | - uses: ./ 66 | name: Setup Xcode 67 | id: setup-xcode 68 | with: 69 | xcode-version: ${{ matrix.xcode-version }} 70 | - name: Print output variables 71 | run: | 72 | echo "Version: ${{ steps.setup-xcode.outputs.version }}" 73 | echo "Path: ${{ steps.setup-xcode.outputs.path }}" -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: Build task 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | schedule: 8 | - cron: 0 0 * * * 9 | 10 | jobs: 11 | Build: 12 | runs-on: macos-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Set Node.JS 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 20.x 21 | 22 | - name: npm install 23 | run: npm install 24 | 25 | - name: Build 26 | run: npm run build 27 | 28 | - name: Run tests 29 | run: npm run test 30 | 31 | - name: Run Prettier 32 | run: npm run format-check 33 | 34 | - name: Lint 35 | run: npm run lint 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4, 4 | "arrowParens": "avoid" 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2020 Maxim Lobanov and contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # setup-xcode 2 | This action is intended to switch between pre-installed versions of Xcode for macOS images in GitHub Actions. 3 | 4 | The list of all available versions can be found in [runner-images](https://github.com/actions/runner-images/blob/master/images/macos/macos-13-Readme.md#xcode) repository. 5 | 6 | # Available parameters 7 | | Argument | Description | Format | 8 | |-------------------------|--------------------------|--------------------| 9 | | `xcode-version` | Specify the Xcode version to use | - `latest` or
- `latest-stable` or
- [SemVer](https://semver.org/) string or
- `-beta` | 10 | 11 | **Notes:** 12 | - `latest-stable` points to the latest stable version of Xcode 13 | - `latest` *includes* beta releases that GitHub actions has installed 14 | - SemVer examples: `14`, `14.1`, `14.3.1`, `^14.3.0` (find more examples in [SemVer cheatsheet](https://devhints.io/semver)) 15 | - `-beta` suffix after SemVer will only select among beta releases that GitHub actions has installed 16 | - If sets a specific version, wraps it to single quotes in YAML like `'12.0'` to pass it as string because GitHub trimmes trailing `.0` from numbers 17 | 18 | # Usage 19 | 20 | Set the latest stable Xcode version: 21 | ``` 22 | jobs: 23 | build: 24 | runs-on: macos-latest 25 | steps: 26 | - uses: maxim-lobanov/setup-xcode@v1 27 | with: 28 | xcode-version: latest-stable 29 | ``` 30 | 31 | Set the latest Xcode version including beta releases: 32 | ``` 33 | jobs: 34 | build: 35 | runs-on: macos-latest 36 | steps: 37 | - uses: maxim-lobanov/setup-xcode@v1 38 | with: 39 | xcode-version: latest 40 | ``` 41 | 42 | Set the specific stable version of Xcode: 43 | ``` 44 | jobs: 45 | build: 46 | runs-on: macos-13 47 | steps: 48 | - uses: maxim-lobanov/setup-xcode@v1 49 | with: 50 | xcode-version: '14.3.1' 51 | ``` 52 | 53 | Set the specific beta version of Xcode: 54 | ``` 55 | jobs: 56 | build: 57 | runs-on: macos-13 58 | steps: 59 | - uses: maxim-lobanov/setup-xcode@v1 60 | with: 61 | xcode-version: '15.0-beta' 62 | ``` 63 | # License 64 | The scripts and documentation in this project are released under the [MIT License](LICENSE) 65 | -------------------------------------------------------------------------------- /__tests__/data/xcode-beta-license.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | licenseID 6 | EA1647 7 | licenseType 8 | Beta 9 | 10 | 11 | -------------------------------------------------------------------------------- /__tests__/data/xcode-empty-license.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | licenseID 6 | EA1647 7 | 8 | 9 | -------------------------------------------------------------------------------- /__tests__/data/xcode-stable-license.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | licenseID 6 | EA1647 7 | licenseType 8 | GM 9 | 10 | 11 | -------------------------------------------------------------------------------- /__tests__/data/xcode-version.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildAliasOf 6 | IDEFrameworks 7 | BuildVersion 8 | 4 9 | CFBundleShortVersionString 10 | 12.0.1 11 | CFBundleVersion 12 | 17220 13 | ProductBuildVersion 14 | 12A7300 15 | ProjectName 16 | IDEFrameworks 17 | SourceVersion 18 | 17220000000000000 19 | 20 | 21 | -------------------------------------------------------------------------------- /__tests__/xcode-selector.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import child from "child_process"; 3 | import * as core from "@actions/core"; 4 | import { XcodeSelector } from "../src/xcode-selector"; 5 | import * as xcodeUtils from "../src/xcode-utils"; 6 | 7 | jest.mock("child_process"); 8 | 9 | const fakeGetXcodeVersionInfoResult: xcodeUtils.XcodeVersion[] = [ 10 | { version: "10.3.0", buildNumber: "", path: "/Applications/Xcode_10.3.app", releaseType: "GM", stable: true }, 11 | { version: "12.0.0", buildNumber: "", path: "/Applications/Xcode_12_beta.app", releaseType: "Beta", stable: false }, 12 | { version: "12.0.0", buildNumber: "", path: "/Applications/Xcode_12.app", releaseType: "GM", stable: true }, 13 | { version: "11.2.1", buildNumber: "", path: "/Applications/Xcode_11.2.1.app", releaseType: "GM", stable: true }, 14 | { version: "11.4.0", buildNumber: "", path: "/Applications/Xcode_11.4.app", releaseType: "GM", stable: true }, 15 | { version: "11.0.0", buildNumber: "", path: "/Applications/Xcode_11.app", releaseType: "GM", stable: true }, 16 | { version: "11.2.0", buildNumber: "", path: "/Applications/Xcode_11.2.app", releaseType: "GM", stable: true }, 17 | ]; 18 | const fakeGetInstalledXcodeAppsResult: string[] = [ 19 | "/Applications/Xcode_10.3.app", 20 | "/Applications/Xcode_12_beta.app", 21 | "/Applications/Xcode_12.app", 22 | "/Applications/Xcode_11.2.1.app", 23 | "/Applications/Xcode_11.4.app", 24 | "/Applications/Xcode_11.app", 25 | "/Applications/Xcode_11.2.app", 26 | "/Applications/Xcode_fake_path.app" 27 | ]; 28 | const expectedGetAllVersionsResult: xcodeUtils.XcodeVersion[] = [ 29 | { version: "12.0.0", buildNumber: "", path: "/Applications/Xcode_12_beta.app", releaseType: "Beta", stable: false }, 30 | { version: "12.0.0", buildNumber: "", path: "/Applications/Xcode_12.app", releaseType: "GM", stable: true }, 31 | { version: "11.4.0", buildNumber: "", path: "/Applications/Xcode_11.4.app", releaseType: "GM", stable: true }, 32 | { version: "11.2.1", buildNumber: "", path: "/Applications/Xcode_11.2.1.app", releaseType: "GM", stable: true }, 33 | { version: "11.2.0", buildNumber: "", path: "/Applications/Xcode_11.2.app", releaseType: "GM", stable: true }, 34 | { version: "11.0.0", buildNumber: "", path: "/Applications/Xcode_11.app", releaseType: "GM", stable: true }, 35 | { version: "10.3.0", buildNumber: "", path: "/Applications/Xcode_10.3.app", releaseType: "GM", stable: true }, 36 | ]; 37 | 38 | describe("XcodeSelector", () => { 39 | describe("getAllVersions", () => { 40 | beforeEach(() => { 41 | jest.spyOn(xcodeUtils, "getInstalledXcodeApps").mockImplementation(() => fakeGetInstalledXcodeAppsResult); 42 | jest.spyOn(xcodeUtils, "getXcodeVersionInfo").mockImplementation((path) => fakeGetXcodeVersionInfoResult.find(app => app.path === path) ?? null); 43 | }); 44 | 45 | afterEach(() => { 46 | jest.resetAllMocks(); 47 | jest.clearAllMocks(); 48 | }); 49 | 50 | it("versions are filtered correctly", () => { 51 | const sel = new XcodeSelector(); 52 | expect(sel.getAllVersions()).toEqual(expectedGetAllVersionsResult); 53 | }); 54 | }); 55 | 56 | describe("findVersion", () => { 57 | it.each([ 58 | ["latest-stable", "12.0.0", true], 59 | ["latest", "12.0.0", false], 60 | ["11", "11.4.0", true], 61 | ["11.x", "11.4.0", true], 62 | ["11.2.x", "11.2.1", true], 63 | ["11.2.0", "11.2.0", true], 64 | ["10.x", "10.3.0", true], 65 | ["~11.2.0", "11.2.1", true], 66 | ["^11.2.0", "11.4.0", true], 67 | ["< 11.0", "10.3.0", true], 68 | ["10.0.0 - 11.2.0", "11.2.0", true], 69 | ["12.0-beta", "12.0.0", false], 70 | ["12.0", "12.0.0", true], 71 | ["give me latest version", null, null] 72 | ] as [string, string | null, boolean | null][])("'%s' -> '%s'", (versionSpec: string, expected: string | null, expectedStable: boolean | null) => { 73 | const sel = new XcodeSelector(); 74 | sel.getAllVersions = (): xcodeUtils.XcodeVersion[] => expectedGetAllVersionsResult; 75 | const xcodeVersion = sel.findVersion(versionSpec) 76 | const matchedVersion = xcodeVersion?.version ?? null; 77 | const isStable = xcodeVersion?.stable ?? null; 78 | expect(matchedVersion).toBe(expected); 79 | expect(isStable).toBe(expectedStable); 80 | }); 81 | }); 82 | 83 | describe("setVersion", () => { 84 | let coreExportVariableSpy: jest.SpyInstance; 85 | let fsExistsSpy: jest.SpyInstance; 86 | let fsSpawnSpy: jest.SpyInstance; 87 | const xcodeVersion: xcodeUtils.XcodeVersion = { 88 | version: "11.4", 89 | buildNumber: "12A7300", 90 | releaseType: "GM", 91 | path: "/Applications/Xcode_11.4.app", 92 | stable: true 93 | }; 94 | 95 | beforeEach(() => { 96 | coreExportVariableSpy = jest.spyOn(core, "exportVariable"); 97 | fsExistsSpy = jest.spyOn(fs, "existsSync"); 98 | fsSpawnSpy = jest.spyOn(child, "spawnSync"); 99 | }); 100 | 101 | afterEach(() => { 102 | jest.resetAllMocks(); 103 | jest.clearAllMocks(); 104 | }); 105 | 106 | it("works correctly", () => { 107 | fsExistsSpy.mockImplementation(() => true); 108 | const sel = new XcodeSelector(); 109 | sel.setVersion(xcodeVersion); 110 | expect(fsSpawnSpy).toHaveBeenCalledWith("sudo", ["xcode-select", "-s", "/Applications/Xcode_11.4.app"]); 111 | expect(coreExportVariableSpy).toHaveBeenCalledWith("MD_APPLE_SDK_ROOT", "/Applications/Xcode_11.4.app"); 112 | }); 113 | 114 | it("error is thrown if version doesn't exist", () => { 115 | fsExistsSpy.mockImplementation(() => false); 116 | const sel = new XcodeSelector(); 117 | expect(() => sel.setVersion(xcodeVersion)).toThrow(); 118 | }); 119 | }); 120 | 121 | }); -------------------------------------------------------------------------------- /__tests__/xcode-utils.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import * as xcodeUtils from "../src/xcode-utils"; 4 | 5 | let pathJoinSpy: jest.SpyInstance; 6 | let readdirSyncSpy: jest.SpyInstance; 7 | let getXcodeReleaseTypeSpy: jest.SpyInstance; 8 | let parsePlistFileSpy: jest.SpyInstance; 9 | 10 | const buildPlistPath = (plistName: string) => { 11 | return `${__dirname}/data/${plistName}`; 12 | }; 13 | 14 | const buildFsDirentItem = (name: string, opt: { isSymbolicLink: boolean; isDirectory: boolean }): fs.Dirent => { 15 | return { 16 | name, 17 | isSymbolicLink: () => opt.isSymbolicLink, 18 | isDirectory: () => opt.isDirectory 19 | } as fs.Dirent; 20 | }; 21 | 22 | const fakeReadDirResults = [ 23 | buildFsDirentItem("Xcode_2.app", { isSymbolicLink: true, isDirectory: false }), 24 | buildFsDirentItem("Xcode.app", { isSymbolicLink: false, isDirectory: true }), 25 | buildFsDirentItem("Xcode12.4.app", { isSymbolicLink: false, isDirectory: true }), 26 | buildFsDirentItem("Xcode_11.1.app", { isSymbolicLink: false, isDirectory: true }), 27 | buildFsDirentItem("Xcode_11.1_beta.app", { isSymbolicLink: true, isDirectory: false }), 28 | buildFsDirentItem("Xcode_11.2.1.app", { isSymbolicLink: false, isDirectory: true }), 29 | buildFsDirentItem("Xcode_11.4.app", { isSymbolicLink: true, isDirectory: false }), 30 | buildFsDirentItem("Xcode_11.4_beta.app", { isSymbolicLink: false, isDirectory: true }), 31 | buildFsDirentItem("Xcode_11.app", { isSymbolicLink: false, isDirectory: true }), 32 | buildFsDirentItem("Xcode_12_beta.app", { isSymbolicLink: false, isDirectory: true }), 33 | buildFsDirentItem("third_party_folder", { isSymbolicLink: false, isDirectory: true }), 34 | ]; 35 | 36 | describe("getInstalledXcodeApps", () => { 37 | beforeEach(() => { 38 | readdirSyncSpy = jest.spyOn(fs, "readdirSync"); 39 | }); 40 | 41 | it("versions are filtered correctly", () => { 42 | readdirSyncSpy.mockImplementation(() => fakeReadDirResults); 43 | const expectedVersions: string[] = [ 44 | "/Applications/Xcode.app", 45 | "/Applications/Xcode12.4.app", 46 | "/Applications/Xcode_11.1.app", 47 | "/Applications/Xcode_11.2.1.app", 48 | "/Applications/Xcode_11.4_beta.app", 49 | "/Applications/Xcode_11.app", 50 | "/Applications/Xcode_12_beta.app", 51 | ]; 52 | 53 | const installedXcodeApps = xcodeUtils.getInstalledXcodeApps(); 54 | expect(installedXcodeApps).toEqual(expectedVersions); 55 | }); 56 | 57 | afterEach(() => { 58 | jest.resetAllMocks(); 59 | jest.clearAllMocks(); 60 | }); 61 | }); 62 | 63 | describe("getXcodeReleaseType", () => { 64 | beforeEach(() => { 65 | pathJoinSpy = jest.spyOn(path, "join"); 66 | }); 67 | 68 | it.each([ 69 | ["xcode-stable-license.plist", "GM"], 70 | ["xcode-beta-license.plist", "Beta"], 71 | ["xcode-empty-license.plist", "Unknown"], 72 | ["xcode-fake.plist", "Unknown"], 73 | ] as [string, xcodeUtils.XcodeVersionReleaseType][])("%s -> %s", (plistName: string, expected: xcodeUtils.XcodeVersionReleaseType) => { 74 | const plistPath = buildPlistPath(plistName); 75 | pathJoinSpy.mockImplementation(() => plistPath); 76 | const releaseType = xcodeUtils.getXcodeReleaseType(""); 77 | expect(releaseType).toBe(expected); 78 | }); 79 | 80 | afterEach(() => { 81 | jest.resetAllMocks(); 82 | jest.clearAllMocks(); 83 | }); 84 | }); 85 | 86 | describe("getXcodeVersionInfo", () => { 87 | beforeEach(() => { 88 | pathJoinSpy = jest.spyOn(path, "join"); 89 | getXcodeReleaseTypeSpy = jest.spyOn(xcodeUtils, "getXcodeReleaseType"); 90 | }); 91 | 92 | afterEach(() => { 93 | jest.resetAllMocks(); 94 | jest.clearAllMocks(); 95 | }); 96 | 97 | it("read version from plist", () => { 98 | const plistPath = buildPlistPath("xcode-version.plist"); 99 | pathJoinSpy.mockImplementation(() => plistPath); 100 | getXcodeReleaseTypeSpy.mockImplementation(() => "GM"); 101 | 102 | const expected: xcodeUtils.XcodeVersion = { 103 | version: "12.0.1", 104 | buildNumber: "12A7300", 105 | path: "fake_path", 106 | releaseType: "GM", 107 | stable: true 108 | }; 109 | 110 | const xcodeInfo = xcodeUtils.getXcodeVersionInfo("fake_path"); 111 | expect(xcodeInfo).toEqual(expected); 112 | }); 113 | 114 | describe("'stable' property", () => { 115 | it.each([ 116 | ["GM", true], 117 | ["Beta", false], 118 | ["Unknown", false] 119 | ])("%s -> %s", (releaseType: string, expected: boolean) => { 120 | const plistPath = buildPlistPath("xcode-version.plist"); 121 | pathJoinSpy.mockImplementation(() => plistPath); 122 | getXcodeReleaseTypeSpy.mockImplementation(() => releaseType); 123 | 124 | const xcodeInfo = xcodeUtils.getXcodeVersionInfo("fake_path"); 125 | expect(xcodeInfo).toBeTruthy(); 126 | expect(xcodeInfo?.stable).toBe(expected); 127 | }); 128 | }); 129 | 130 | describe("coerce validation", () => { 131 | beforeEach(() => { 132 | parsePlistFileSpy = jest.spyOn(xcodeUtils, "parsePlistFile"); 133 | }); 134 | 135 | afterEach(() => { 136 | jest.resetAllMocks(); 137 | jest.clearAllMocks(); 138 | }); 139 | 140 | it("full version", () => { 141 | parsePlistFileSpy.mockImplementation(() => { 142 | return { 143 | CFBundleShortVersionString: "12.0.1", ProductBuildVersion: "2FF" 144 | }; 145 | }); 146 | getXcodeReleaseTypeSpy.mockImplementation(() => "GM"); 147 | 148 | const xcodeInfo = xcodeUtils.getXcodeVersionInfo("fake_path"); 149 | expect(xcodeInfo?.version).toBe("12.0.1"); 150 | }); 151 | 152 | it("partial version", () => { 153 | parsePlistFileSpy.mockImplementation(() => { 154 | return { 155 | CFBundleShortVersionString: "10.3", ProductBuildVersion: "2FF" 156 | }; 157 | }); 158 | getXcodeReleaseTypeSpy.mockImplementation(() => "GM"); 159 | 160 | const xcodeInfo = xcodeUtils.getXcodeVersionInfo("fake_path"); 161 | expect(xcodeInfo?.version).toBe("10.3.0"); 162 | }); 163 | 164 | it("invalid version", () => { 165 | parsePlistFileSpy.mockImplementation(() => { 166 | return { 167 | CFBundleShortVersionString: "fake_version", ProductBuildVersion: "2FF" 168 | }; 169 | }); 170 | getXcodeReleaseTypeSpy.mockImplementation(() => "GM"); 171 | 172 | const xcodeInfo = xcodeUtils.getXcodeVersionInfo("fake_path"); 173 | expect(xcodeInfo).toBeNull(); 174 | }); 175 | }); 176 | }); 177 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Setup Xcode version' 2 | author: 'Maxim Lobanov' 3 | description: 'Set up your GitHub Actions workflow with a specific version of Xcode' 4 | inputs: 5 | xcode-version: 6 | description: 'Version of Xcode to use' 7 | required: false 8 | default: latest 9 | runs: 10 | using: 'node20' 11 | main: 'dist/index.js' 12 | branding: 13 | icon: 'code' 14 | color: 'blue' 15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | testEnvironment: 'node', 5 | testMatch: ['**/*.test.ts'], 6 | testRunner: 'jest-circus/runner', 7 | transform: { 8 | '^.+\\.ts$': 'ts-jest' 9 | }, 10 | verbose: true 11 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "setup-xcode", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "Set up your GitHub Actions workflow with a specific version of Xcode", 6 | "main": "lib/setup-xcode.js", 7 | "scripts": { 8 | "build": "tsc && ncc build", 9 | "test": "jest", 10 | "format": "npx prettier --write src/**/*", 11 | "format-check": "npx prettier --check src/**/*", 12 | "lint": "npx eslint **/*.ts" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/maxim-lobanov/setup-xcode.git" 17 | }, 18 | "keywords": [ 19 | "actions", 20 | "xcode", 21 | "setup" 22 | ], 23 | "author": "Maxim Lobanov", 24 | "license": "MIT", 25 | "dependencies": { 26 | "@actions/core": "^1.10.1", 27 | "plist": "^3.1.0", 28 | "semver": "^7.5.4" 29 | }, 30 | "devDependencies": { 31 | "@types/jest": "^29.5.5", 32 | "@types/node": "^20.6.3", 33 | "@types/plist": "^3.0.2", 34 | "@types/semver": "^7.5.2", 35 | "@typescript-eslint/eslint-plugin": "^6.7.2", 36 | "@typescript-eslint/parser": "^6.7.2", 37 | "@vercel/ncc": "^0.34.0", 38 | "eslint": "^8.50.0", 39 | "eslint-config-prettier": "^9.0.0", 40 | "eslint-plugin-jest": "^27.4.0", 41 | "jest": "^29.7.0", 42 | "jest-circus": "^29.7.0", 43 | "prettier": "^3.0.3", 44 | "ts-jest": "^29.1.1", 45 | "typescript": "^5.2.2" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/setup-xcode.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import { XcodeSelector } from "./xcode-selector"; 3 | 4 | const run = (): void => { 5 | try { 6 | if (process.platform !== "darwin") { 7 | throw new Error( 8 | `This task is intended only for macOS platform. It can't be run on '${process.platform}' platform`, 9 | ); 10 | } 11 | 12 | const versionSpec = core.getInput("xcode-version", { required: false }); 13 | core.info(`Switching Xcode to version '${versionSpec}'...`); 14 | 15 | const selector = new XcodeSelector(); 16 | if (core.isDebug()) { 17 | core.startGroup("Available Xcode versions:"); 18 | core.debug(JSON.stringify(selector.getAllVersions(), null, 2)); 19 | core.endGroup(); 20 | } 21 | const targetVersion = selector.findVersion(versionSpec); 22 | 23 | if (!targetVersion) { 24 | console.log("Available versions:"); 25 | console.table(selector.getAllVersions()); 26 | throw new Error( 27 | `Could not find Xcode version that satisfied version spec: '${versionSpec}'`, 28 | ); 29 | } 30 | 31 | core.debug( 32 | `Xcode ${targetVersion.version} (${targetVersion.buildNumber}) (${targetVersion.path}) will be set`, 33 | ); 34 | selector.setVersion(targetVersion); 35 | core.info(`Xcode is set to ${targetVersion.version} (${targetVersion.buildNumber})`); 36 | 37 | core.setOutput("version", targetVersion.version); 38 | core.setOutput("path", targetVersion.path); 39 | } catch (error: unknown) { 40 | core.setFailed((error as Error).message); 41 | } 42 | }; 43 | 44 | run(); 45 | -------------------------------------------------------------------------------- /src/xcode-selector.ts: -------------------------------------------------------------------------------- 1 | import * as child from "child_process"; 2 | import * as core from "@actions/core"; 3 | import * as fs from "fs"; 4 | import * as semver from "semver"; 5 | import { getInstalledXcodeApps, getXcodeVersionInfo, XcodeVersion } from "./xcode-utils"; 6 | 7 | export class XcodeSelector { 8 | public getAllVersions(): XcodeVersion[] { 9 | const potentialXcodeApps = getInstalledXcodeApps().map(appPath => 10 | getXcodeVersionInfo(appPath), 11 | ); 12 | const xcodeVersions = potentialXcodeApps.filter((app): app is XcodeVersion => !!app); 13 | 14 | // sort versions array by descending to make sure that the newest version will be picked up 15 | return xcodeVersions.sort((first, second) => semver.compare(second.version, first.version)); 16 | } 17 | 18 | public findVersion(versionSpec: string): XcodeVersion | null { 19 | const availableVersions = this.getAllVersions(); 20 | if (availableVersions.length === 0) { 21 | return null; 22 | } 23 | 24 | if (versionSpec === "latest") { 25 | return availableVersions[0]; 26 | } 27 | 28 | if (versionSpec === "latest-stable") { 29 | return availableVersions.filter(ver => ver.stable)[0]; 30 | } 31 | 32 | const betaSuffix = "-beta"; 33 | let isStable = true; 34 | if (versionSpec.endsWith(betaSuffix)) { 35 | isStable = false; 36 | versionSpec = versionSpec.slice(0, -betaSuffix.length); 37 | } 38 | 39 | return ( 40 | availableVersions 41 | .filter(ver => ver.stable === isStable) 42 | .find(ver => semver.satisfies(ver.version, versionSpec)) ?? null 43 | ); 44 | } 45 | 46 | setVersion(xcodeVersion: XcodeVersion): void { 47 | if (!fs.existsSync(xcodeVersion.path)) { 48 | throw new Error(`Invalid version: Directory '${xcodeVersion.path}' doesn't exist`); 49 | } 50 | 51 | child.spawnSync("sudo", ["xcode-select", "-s", xcodeVersion.path]); 52 | 53 | // set "MD_APPLE_SDK_ROOT" environment variable to specify Xcode for Mono and Xamarin 54 | core.exportVariable("MD_APPLE_SDK_ROOT", xcodeVersion.path); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/xcode-utils.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import * as fs from "fs"; 3 | import * as core from "@actions/core"; 4 | import * as plist from "plist"; 5 | import * as semver from "semver"; 6 | 7 | export type XcodeVersionReleaseType = "GM" | "Beta" | "Unknown"; 8 | 9 | export interface XcodeVersion { 10 | version: string; 11 | buildNumber: string; 12 | path: string; 13 | releaseType: XcodeVersionReleaseType; 14 | stable: boolean; 15 | } 16 | 17 | export const parsePlistFile = (plistPath: string): plist.PlistObject | null => { 18 | if (!fs.existsSync(plistPath)) { 19 | core.debug(`Unable to open plist file. File doesn't exist on path '${plistPath}'`); 20 | return null; 21 | } 22 | 23 | const plistRawContent = fs.readFileSync(plistPath, "utf8"); 24 | return plist.parse(plistRawContent) as plist.PlistObject; 25 | }; 26 | 27 | export const getInstalledXcodeApps = (): string[] => { 28 | const applicationsDirectory = "/Applications"; 29 | const xcodeAppFilenameRegex = /^Xcode.*\.app$/; 30 | 31 | const allApplicationsChildItems = fs.readdirSync(applicationsDirectory, { 32 | encoding: "utf8", 33 | withFileTypes: true, 34 | }); 35 | const allApplicationsRealItems = allApplicationsChildItems.filter( 36 | child => !child.isSymbolicLink() && child.isDirectory(), 37 | ); 38 | const xcodeAppsItems = allApplicationsRealItems.filter(app => 39 | xcodeAppFilenameRegex.test(app.name), 40 | ); 41 | return xcodeAppsItems.map(child => path.join(applicationsDirectory, child.name)); 42 | }; 43 | 44 | export const getXcodeReleaseType = (xcodeRootPath: string): XcodeVersionReleaseType => { 45 | const licenseInfo = parsePlistFile( 46 | path.join(xcodeRootPath, "Contents", "Resources", "LicenseInfo.plist"), 47 | ); 48 | const licenseType = licenseInfo?.licenseType?.toString()?.toLowerCase(); 49 | if (!licenseType) { 50 | core.debug("Unable to determine Xcode version type based on license plist"); 51 | core.debug("Xcode License plist doesn't contain 'licenseType' property"); 52 | return "Unknown"; 53 | } 54 | 55 | return licenseType.includes("beta") ? "Beta" : "GM"; 56 | }; 57 | 58 | export const getXcodeVersionInfo = (xcodeRootPath: string): XcodeVersion | null => { 59 | const versionInfo = parsePlistFile(path.join(xcodeRootPath, "Contents", "version.plist")); 60 | const xcodeVersion = semver.coerce(versionInfo?.CFBundleShortVersionString?.toString()); 61 | const xcodeBuildNumber = versionInfo?.ProductBuildVersion?.toString(); 62 | if (!xcodeVersion || !semver.valid(xcodeVersion)) { 63 | core.debug(`Unable to retrieve Xcode version info on path '${xcodeRootPath}'`); 64 | return null; 65 | } 66 | 67 | const releaseType = getXcodeReleaseType(xcodeRootPath); 68 | 69 | return { 70 | version: xcodeVersion.version, 71 | buildNumber: xcodeBuildNumber, 72 | releaseType: releaseType, 73 | stable: releaseType === "GM", 74 | path: xcodeRootPath, 75 | } as XcodeVersion; 76 | }; 77 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["**/*.ts"], 4 | "exclude": [] 5 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "module": "commonjs", 5 | "outDir": "./lib", 6 | "rootDir": "./src", 7 | "esModuleInterop": true, 8 | 9 | /* Strict Type-Checking Options */ 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "strictFunctionTypes": true, 14 | 15 | /* Additional Checks */ 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "noImplicitReturns": true 19 | }, 20 | "include": ["./src/**/*.ts"], 21 | "exclude": ["node_modules", "./__tests__/**/*.ts"] 22 | } --------------------------------------------------------------------------------