├── .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 | }
--------------------------------------------------------------------------------