diff --git a/playbooks/macos_actions_runner.yml b/playbooks/macos_actions_runner.yml new file mode 100644 index 00000000..34c40aca --- /dev/null +++ b/playbooks/macos_actions_runner.yml @@ -0,0 +1,7 @@ +--- +# A macOS Actions runner is a host process, not an ARC runner pod: Xcode and macOS archive jobs cannot run in a Linux container. The role registers it with a short-lived GitHub App token and supervises its persistent process through launchd. +- name: Install managed macOS Actions runners + hosts: "{{ github_runner_macos_actions_runner_hosts | default(github_runner_cluster_group | default('github_runner_cluster')) }}" + gather_facts: true + roles: + - exadev.github_runner.github_runner_macos_actions_runner diff --git a/roles/github_runner_macos_actions_runner/defaults/main.yml b/roles/github_runner_macos_actions_runner/defaults/main.yml new file mode 100644 index 00000000..2dc79f50 --- /dev/null +++ b/roles/github_runner_macos_actions_runner/defaults/main.yml @@ -0,0 +1,21 @@ +--- +# A persistent macOS runner is deliberately separate from ARC: runner pods require Linux containers, while Xcode and macOS archive jobs must run on the host OS. Restrict it to one repository whose workflows are equally trusted, because a persistent native runner is not a disposable cross-repository execution environment. The registration credential is minted only while Ansible configures it; launchd runs the already-registered process without any GitHub credential. +github_runner_macos_actions_runner_enabled: false +github_runner_macos_actions_runner_organization: "" +github_runner_macos_actions_runner_repository: "" +github_runner_macos_actions_runner_app_id: "" +github_runner_macos_actions_runner_installation_id: "" +github_runner_macos_actions_runner_private_key: "" +github_runner_macos_actions_runner_private_key_op_reference: "" +github_runner_macos_actions_runner_label: exadev-macos +github_runner_macos_actions_runner_name: "{{ inventory_hostname }}-macos" +github_runner_macos_actions_runner_user: github-runner +github_runner_macos_actions_runner_uid: 401 +github_runner_macos_actions_runner_version: "2.338.2" +github_runner_macos_actions_runner_sha256: 25a065af409ffa317ab4d5a3b614dd15fc9c40f2172bc1b35a1b1258e919a388 +# Repository identity is part of the directory. Changing the repository therefore creates a new unregistered runner rather than leaving a runner enrolled in the old scope. +github_runner_macos_actions_runner_root: "/var/lib/github-actions-runner/{{ github_runner_macos_actions_runner_organization }}-{{ github_runner_macos_actions_runner_repository }}" +github_runner_macos_actions_runner_work_dir: "{{ github_runner_macos_actions_runner_root }}/_work" +github_runner_macos_actions_runner_plist: "/Library/LaunchDaemons/io.exadev.github-actions-runner.{{ github_runner_macos_actions_runner_name }}.plist" +github_runner_macos_actions_runner_launchd_label: "io.exadev.github-actions-runner.{{ github_runner_macos_actions_runner_name }}" +github_runner_macos_actions_runner_path: /opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin diff --git a/roles/github_runner_macos_actions_runner/handlers/main.yml b/roles/github_runner_macos_actions_runner/handlers/main.yml new file mode 100644 index 00000000..c258c052 --- /dev/null +++ b/roles/github_runner_macos_actions_runner/handlers/main.yml @@ -0,0 +1,13 @@ +--- +- name: Restart macOS Actions runner + become: true + block: + - name: Stop the previous runner launch daemon + ansible.builtin.command: + argv: [launchctl, bootout, "system/{{ github_runner_macos_actions_runner_launchd_label }}"] + failed_when: false + changed_when: false + - name: Start the runner launch daemon + ansible.builtin.command: + argv: [launchctl, bootstrap, system, "{{ github_runner_macos_actions_runner_plist }}"] + changed_when: true diff --git a/roles/github_runner_macos_actions_runner/meta/argument_specs.yml b/roles/github_runner_macos_actions_runner/meta/argument_specs.yml new file mode 100644 index 00000000..fedf910a --- /dev/null +++ b/roles/github_runner_macos_actions_runner/meta/argument_specs.yml @@ -0,0 +1,61 @@ +--- +argument_specs: + main: + short_description: Registers a macOS runner as a launchd-managed ephemeral GitHub Actions runner. + options: + github_runner_macos_actions_runner_enabled: + type: bool + default: false + github_runner_macos_actions_runner_organization: + type: str + default: "" + github_runner_macos_actions_runner_repository: + type: str + default: "" + github_runner_macos_actions_runner_app_id: + type: str + default: "" + github_runner_macos_actions_runner_installation_id: + type: str + default: "" + github_runner_macos_actions_runner_private_key: + type: str + default: "" + no_log: true + github_runner_macos_actions_runner_private_key_op_reference: + type: str + default: "" + no_log: true + github_runner_macos_actions_runner_label: + type: str + default: exadev-macos + github_runner_macos_actions_runner_name: + type: str + default: "" + github_runner_macos_actions_runner_user: + type: str + default: github-runner + github_runner_macos_actions_runner_uid: + type: int + default: 401 + github_runner_macos_actions_runner_version: + type: str + default: "2.338.2" + github_runner_macos_actions_runner_sha256: + type: str + default: 25a065af409ffa317ab4d5a3b614dd15fc9c40f2172bc1b35a1b1258e919a388 + github_runner_macos_actions_runner_root: + type: path + default: "" + github_runner_macos_actions_runner_work_dir: + type: path + default: "" + github_runner_macos_actions_runner_plist: + type: path + default: "" + github_runner_macos_actions_runner_launchd_label: + type: str + default: "" + github_runner_macos_actions_runner_path: + type: str + default: /opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin diff --git a/roles/github_runner_macos_actions_runner/meta/main.yml b/roles/github_runner_macos_actions_runner/meta/main.yml new file mode 100644 index 00000000..71dbe95c --- /dev/null +++ b/roles/github_runner_macos_actions_runner/meta/main.yml @@ -0,0 +1,10 @@ +--- +galaxy_info: + author: ExaDev + description: Registers a managed macOS GitHub Actions runner through launchd. + license: MIT + min_ansible_version: "2.15" + platforms: + - name: MacOSX + versions: [all] +dependencies: [] diff --git a/roles/github_runner_macos_actions_runner/tasks/main.yml b/roles/github_runner_macos_actions_runner/tasks/main.yml new file mode 100644 index 00000000..a425b7ba --- /dev/null +++ b/roles/github_runner_macos_actions_runner/tasks/main.yml @@ -0,0 +1,252 @@ +--- +- name: Read the host platform and connecting user + ansible.builtin.setup: + gather_subset: + - "!all" + - "!min" + - platform + - user + when: "'system' not in ansible_facts or 'user_id' not in ansible_facts" + +- name: End the role on a non-macOS host + ansible.builtin.meta: end_role + when: ansible_facts['system'] != 'Darwin' + +- name: Read the macOS runner App key from 1Password + delegate_to: localhost + ansible.builtin.command: + argv: [op, read, "{{ github_runner_macos_actions_runner_private_key_op_reference }}"] + register: github_runner_macos_actions_runner_op_key + changed_when: false + no_log: true + when: + - github_runner_macos_actions_runner_enabled | bool + - github_runner_macos_actions_runner_private_key | length == 0 + - github_runner_macos_actions_runner_private_key_op_reference | length > 0 + +- name: Set the macOS runner App key + ansible.builtin.set_fact: + github_runner_macos_actions_runner_private_key: "{{ github_runner_macos_actions_runner_op_key.stdout }}" + no_log: true + when: github_runner_macos_actions_runner_op_key is not skipped + +- name: Require the macOS runner settings + ansible.builtin.assert: + that: + - github_runner_macos_actions_runner_organization | length > 0 + - github_runner_macos_actions_runner_repository | length > 0 + - github_runner_macos_actions_runner_app_id | string | regex_search('^[0-9]+$') is not none + - github_runner_macos_actions_runner_installation_id | string | regex_search('^[0-9]+$') is not none + - github_runner_macos_actions_runner_private_key | length > 0 + - github_runner_macos_actions_runner_label | regex_search('^[A-Za-z0-9_.-]+$') is not none + quiet: true + when: github_runner_macos_actions_runner_enabled | bool + no_log: true + +- name: Stop and remove a disabled macOS runner + become: true + when: not (github_runner_macos_actions_runner_enabled | bool) + block: + - name: Remove the runner launch daemon + ansible.builtin.file: + path: "{{ github_runner_macos_actions_runner_plist }}" + state: absent + - name: Stop the disabled runner service + ansible.builtin.command: + argv: [launchctl, bootout, "system/{{ github_runner_macos_actions_runner_launchd_label }}"] + failed_when: false + changed_when: false + +- name: Configure the enabled macOS runner + when: github_runner_macos_actions_runner_enabled | bool + block: + - name: Read the runner account + ansible.builtin.command: + argv: [dscl, ., -read, "/Users/{{ github_runner_macos_actions_runner_user }}", UniqueID] + register: github_runner_macos_actions_runner_account + changed_when: false + failed_when: false + check_mode: false + + - name: Create the unprivileged runner account + become: true + ansible.builtin.command: + argv: + - sysadminctl + - -addUser + - "{{ github_runner_macos_actions_runner_user }}" + - -fullName + - GitHub Actions Runner + - -UID + - "{{ github_runner_macos_actions_runner_uid }}" + - -shell + - /usr/bin/false + - -home + - "{{ github_runner_macos_actions_runner_root }}" + - -password + - '*' + no_log: true + changed_when: true + when: github_runner_macos_actions_runner_account.rc != 0 + + - name: Require the runner account's expected identity + ansible.builtin.assert: + that: + - "github_runner_macos_actions_runner_account.rc != 0 or github_runner_macos_actions_runner_account.stdout is regex('UniqueID: ' ~ (github_runner_macos_actions_runner_uid | string) ~ '$')" + quiet: true + when: not ansible_check_mode + + - name: Create the runner directory + become: true + ansible.builtin.file: + path: "{{ github_runner_macos_actions_runner_root }}" + state: directory + owner: "{{ github_runner_macos_actions_runner_user }}" + mode: "0750" + + - name: Check the installed runner version + become: true + become_user: "{{ github_runner_macos_actions_runner_user }}" + ansible.builtin.command: + argv: ["{{ github_runner_macos_actions_runner_root }}/bin/Runner.Listener", --version] + register: github_runner_macos_actions_runner_installed_version + changed_when: false + failed_when: false + check_mode: false + + - name: Download the pinned Apple Silicon runner package + become: true + ansible.builtin.get_url: + url: "https://github.com/actions/runner/releases/download/v{{ github_runner_macos_actions_runner_version }}/actions-runner-osx-arm64-{{ github_runner_macos_actions_runner_version }}.tar.gz" + dest: "{{ github_runner_macos_actions_runner_root }}-{{ github_runner_macos_actions_runner_version }}.tar.gz" + checksum: "sha256:{{ github_runner_macos_actions_runner_sha256 }}" + mode: "0644" + when: github_runner_macos_actions_runner_installed_version.stdout | trim != github_runner_macos_actions_runner_version + + - name: Stop the runner before replacing its installation + become: true + ansible.builtin.command: + argv: [launchctl, bootout, "system/{{ github_runner_macos_actions_runner_launchd_label }}"] + failed_when: false + changed_when: false + when: github_runner_macos_actions_runner_installed_version.stdout | trim != github_runner_macos_actions_runner_version + + - name: Remove the prior runner installation + ansible.builtin.file: + path: "{{ github_runner_macos_actions_runner_root }}" + state: absent + when: github_runner_macos_actions_runner_installed_version.stdout | trim != github_runner_macos_actions_runner_version + + - name: Recreate the runner directory + become: true + ansible.builtin.file: + path: "{{ github_runner_macos_actions_runner_root }}" + state: directory + owner: "{{ github_runner_macos_actions_runner_user }}" + mode: "0750" + when: github_runner_macos_actions_runner_installed_version.stdout | trim != github_runner_macos_actions_runner_version + + - name: Install the pinned Apple Silicon runner package + become: true + ansible.builtin.unarchive: + src: "{{ github_runner_macos_actions_runner_root }}-{{ github_runner_macos_actions_runner_version }}.tar.gz" + dest: "{{ github_runner_macos_actions_runner_root }}" + remote_src: true + owner: "{{ github_runner_macos_actions_runner_user }}" + when: github_runner_macos_actions_runner_installed_version.stdout | trim != github_runner_macos_actions_runner_version + + - name: Check whether the runner is registered + ansible.builtin.stat: + path: "{{ github_runner_macos_actions_runner_root }}/.runner" + register: github_runner_macos_actions_runner_registration + + - name: Register the runner through the GitHub App + delegate_to: localhost + check_mode: false + no_log: true + when: not github_runner_macos_actions_runner_registration.stat.exists + block: + - name: Create a temporary App key file + ansible.builtin.tempfile: + state: file + suffix: ".pem" + register: github_runner_macos_actions_runner_key_file + + - name: Write the GitHub App key + ansible.builtin.copy: + content: "{{ github_runner_macos_actions_runner_private_key }}" + dest: "{{ github_runner_macos_actions_runner_key_file.path }}" + mode: "0600" + + - name: Sign the GitHub App JWT + ansible.builtin.script: + cmd: "{{ role_path }}/../github_runner_arc/files/generate_app_jwt.py {{ github_runner_macos_actions_runner_app_id | quote }} {{ github_runner_macos_actions_runner_key_file.path | quote }}" + executable: python3 + changed_when: false + register: github_runner_macos_actions_runner_jwt + + - name: Mint a self-hosted runner installation token + ansible.builtin.uri: + url: "https://api.github.com/app/installations/{{ github_runner_macos_actions_runner_installation_id }}/access_tokens" + method: POST + headers: + Authorization: "Bearer {{ github_runner_macos_actions_runner_jwt.stdout | trim }}" + Accept: application/vnd.github+json + X-GitHub-Api-Version: "2022-11-28" + body_format: json + body: + permissions: + administration: write + metadata: read + status_code: 201 + register: github_runner_macos_actions_runner_installation_token + + - name: Request a runner registration token + ansible.builtin.uri: + url: "https://api.github.com/repos/{{ github_runner_macos_actions_runner_organization }}/{{ github_runner_macos_actions_runner_repository }}/actions/runners/registration-token" + method: POST + headers: + Authorization: "Bearer {{ github_runner_macos_actions_runner_installation_token.json.token }}" + Accept: application/vnd.github+json + X-GitHub-Api-Version: "2022-11-28" + status_code: 201 + register: github_runner_macos_actions_runner_registration_token + always: + - name: Remove the temporary App key file + ansible.builtin.file: + path: "{{ github_runner_macos_actions_runner_key_file.path }}" + state: absent + when: github_runner_macos_actions_runner_key_file.path is defined + + - name: Configure the macOS runner + become: true + become_user: "{{ github_runner_macos_actions_runner_user }}" + ansible.builtin.command: + argv: + - "{{ github_runner_macos_actions_runner_root }}/config.sh" + - --unattended + - --url + - "https://github.com/{{ github_runner_macos_actions_runner_organization }}/{{ github_runner_macos_actions_runner_repository }}" + - --name + - "{{ github_runner_macos_actions_runner_name }}" + - --labels + - "{{ github_runner_macos_actions_runner_label }}" + - --work + - "{{ github_runner_macos_actions_runner_work_dir }}" + - --replace + environment: + ACTIONS_RUNNER_INPUT_TOKEN: "{{ github_runner_macos_actions_runner_registration_token.json.token }}" + no_log: true + changed_when: true + when: not github_runner_macos_actions_runner_registration.stat.exists + + - name: Install the runner launch daemon + become: true + ansible.builtin.template: + src: runner.plist.j2 + dest: "{{ github_runner_macos_actions_runner_plist }}" + owner: root + group: wheel + mode: "0644" + validate: plutil -lint %s + notify: Restart macOS Actions runner diff --git a/roles/github_runner_macos_actions_runner/templates/runner.plist.j2 b/roles/github_runner_macos_actions_runner/templates/runner.plist.j2 new file mode 100644 index 00000000..62fb24d0 --- /dev/null +++ b/roles/github_runner_macos_actions_runner/templates/runner.plist.j2 @@ -0,0 +1,29 @@ + + + + + Label + {{ github_runner_macos_actions_runner_launchd_label }} + ProgramArguments + + {{ github_runner_macos_actions_runner_root }}/run.sh + + WorkingDirectory + {{ github_runner_macos_actions_runner_root }} + UserName + {{ github_runner_macos_actions_runner_user }} + EnvironmentVariables + + PATH + {{ github_runner_macos_actions_runner_path }} + + KeepAlive + + RunAtLoad + + StandardOutPath + {{ github_runner_macos_actions_runner_root }}/runner.stdout.log + StandardErrorPath + {{ github_runner_macos_actions_runner_root }}/runner.stderr.log + +