From b680917c3d5d11bb03ea1e6f061d8810ded92e22 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 5 Nov 2026 22:03:57 +0000 Subject: [PATCH 1/2] fix: protect macOS runner installations during provisioning --- .../handlers/main.yml | 8 - .../tasks/main.yml | 149 +++++++++++------- tests/unit/test_macos_actions_runner_check.py | 54 +++++-- 3 files changed, 137 insertions(+), 74 deletions(-) diff --git a/roles/github_runner_macos_actions_runner/handlers/main.yml b/roles/github_runner_macos_actions_runner/handlers/main.yml index c54d58f4..3e758198 100644 --- a/roles/github_runner_macos_actions_runner/handlers/main.yml +++ b/roles/github_runner_macos_actions_runner/handlers/main.yml @@ -1,12 +1,4 @@ --- -- name: Stop the previous macOS Actions runner launch daemon - listen: Restart macOS Actions runner - become: true - ansible.builtin.command: - argv: [launchctl, bootout, "system/{{ github_runner_macos_actions_runner_launchd_label }}"] - failed_when: false - changed_when: false - - name: Start the macOS Actions runner launch daemon listen: Restart macOS Actions runner become: true diff --git a/roles/github_runner_macos_actions_runner/tasks/main.yml b/roles/github_runner_macos_actions_runner/tasks/main.yml index 6c1bc7eb..590c901c 100644 --- a/roles/github_runner_macos_actions_runner/tasks/main.yml +++ b/roles/github_runner_macos_actions_runner/tasks/main.yml @@ -12,6 +12,99 @@ ansible.builtin.meta: end_role when: ansible_facts['system'] != 'Darwin' +- name: Inspect the runner directory before making changes + become: true + ansible.builtin.stat: + path: "{{ github_runner_macos_actions_runner_root }}" + follow: false + register: github_runner_macos_actions_runner_directory + +- name: Inspect the runner registration before making changes + become: true + ansible.builtin.stat: + path: "{{ github_runner_macos_actions_runner_root }}/.runner" + follow: false + register: github_runner_macos_actions_runner_registration + +- name: Inspect the launchd service before making changes + become: true + ansible.builtin.command: + argv: [launchctl, print, "system/{{ github_runner_macos_actions_runner_launchd_label }}"] + register: github_runner_macos_actions_runner_service + changed_when: false + failed_when: false + check_mode: false + +- name: Refuse unknown launchd service state + ansible.builtin.assert: + that: + - github_runner_macos_actions_runner_service.rc in [0, 113] + fail_msg: Cannot establish whether the macOS runner service exists; leaving it untouched. + +- name: Refuse disabling an existing runner + ansible.builtin.assert: + that: + - not github_runner_macos_actions_runner_directory.stat.exists + - not github_runner_macos_actions_runner_registration.stat.exists + - github_runner_macos_actions_runner_service.rc == 113 + fail_msg: Disabling an existing macOS runner is unsupported until a race-free mechanism exists. + when: not (github_runner_macos_actions_runner_enabled | bool) + +- name: End the role when disabled and absent + ansible.builtin.meta: end_role + when: not (github_runner_macos_actions_runner_enabled | bool) + +- name: Check the registered runner version + become: true + ansible.builtin.command: + argv: ["{{ github_runner_macos_actions_runner_root }}/bin/Runner.Listener", --version] + register: github_runner_macos_actions_runner_existing_version + changed_when: false + failed_when: false + check_mode: false + when: github_runner_macos_actions_runner_registration.stat.exists + +- name: Refuse an upgrade or unrecognised registration + ansible.builtin.assert: + that: + - github_runner_macos_actions_runner_existing_version.rc == 0 + - github_runner_macos_actions_runner_existing_version.stdout | trim == github_runner_macos_actions_runner_version + fail_msg: Upgrading or replacing a registered macOS runner is unsupported until a race-free mechanism exists. + when: github_runner_macos_actions_runner_registration.stat.exists + +- name: Leave an existing matching registration untouched + ansible.builtin.meta: end_role + when: github_runner_macos_actions_runner_registration.stat.exists + +- name: Refuse an existing launchd service + ansible.builtin.assert: + that: + - github_runner_macos_actions_runner_service.rc == 113 + fail_msg: An existing macOS runner service cannot be safely replaced. + +- name: Require a real runner directory + ansible.builtin.assert: + that: + - not github_runner_macos_actions_runner_directory.stat.exists or (github_runner_macos_actions_runner_directory.stat.isdir and not github_runner_macos_actions_runner_directory.stat.islnk) + fail_msg: The runner path is not a real directory; leaving it untouched. + +- name: Inspect the existing runner directory contents + become: true + ansible.builtin.find: + paths: "{{ github_runner_macos_actions_runner_root }}" + hidden: true + file_type: any + recurse: false + register: github_runner_macos_actions_runner_contents + when: github_runner_macos_actions_runner_directory.stat.exists + +- name: Refuse a non-empty runner directory + ansible.builtin.assert: + that: + - github_runner_macos_actions_runner_contents.matched == 0 + fail_msg: The runner directory is not empty; leaving its installation and workspace untouched. + when: github_runner_macos_actions_runner_directory.stat.exists + - name: Read the macOS runner App key from 1Password delegate_to: localhost ansible.builtin.command: @@ -44,20 +137,6 @@ 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: @@ -110,17 +189,6 @@ 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 - when: not ansible_check_mode or github_runner_macos_actions_runner_account.rc == 0 - - name: Download the pinned Apple Silicon runner package become: true ansible.builtin.get_url: @@ -130,30 +198,6 @@ mode: "0644" when: - not ansible_check_mode - - github_runner_macos_actions_runner_installed_version.stdout | default('') | 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 | default('') | 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 | default('') | 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 | default('') | trim != github_runner_macos_actions_runner_version - name: Install the pinned Apple Silicon runner package become: true @@ -164,12 +208,6 @@ owner: "{{ github_runner_macos_actions_runner_user }}" when: - not ansible_check_mode - - github_runner_macos_actions_runner_installed_version.stdout | default('') | 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 @@ -245,7 +283,6 @@ - "{{ 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 diff --git a/tests/unit/test_macos_actions_runner_check.py b/tests/unit/test_macos_actions_runner_check.py index 1902ac09..2cfddd26 100644 --- a/tests/unit/test_macos_actions_runner_check.py +++ b/tests/unit/test_macos_actions_runner_check.py @@ -1,4 +1,4 @@ -"""Check-mode regression tests for a macOS runner on a host with no runner account.""" +"""Safety checks for first-install-only macOS runner provisioning.""" from pathlib import Path import unittest @@ -10,19 +10,53 @@ class MacOSActionsRunnerCheckTest(unittest.TestCase): - def test_first_install_does_not_become_the_not_yet_created_user(self) -> None: - tasks = yaml.safe_load((ROLE / "tasks" / "main.yml").read_text()) - enabled = next(task for task in tasks if task["name"] == "Configure the enabled macOS runner") - entries = {task["name"]: task for task in enabled["block"]} - self.assertIn("not ansible_check_mode or github_runner_macos_actions_runner_account.rc == 0", entries["Check the installed runner version"]["when"]) + def setUp(self) -> None: + self.tasks = yaml.safe_load((ROLE / "tasks" / "main.yml").read_text()) + self.entries = {task["name"]: task for task in self.tasks} + enabled = self.entries["Configure the enabled macOS runner"] + self.install = {task["name"]: task for task in enabled["block"]} + + def test_empty_root_owned_directory_is_populated_without_removal(self) -> None: + self.assertTrue(self.entries["Inspect the existing runner directory contents"]["become"]) + find = self.entries["Inspect the existing runner directory contents"]["ansible.builtin.find"] + self.assertTrue(find["hidden"]) + self.assertEqual(find["file_type"], "any") + self.assertIn("matched == 0", str(self.entries["Refuse a non-empty runner directory"])) + self.assertTrue(self.install["Install the pinned Apple Silicon runner package"]["become"]) + self.assertNotIn("state: absent", str(self.install["Create the runner directory"])) + self.assertNotIn("Remove the prior runner installation", self.install) + + def test_registration_is_checked_before_changes_and_matching_install_is_untouched(self) -> None: + names = list(self.entries) + self.assertLess(names.index("Refuse an upgrade or unrecognised registration"), names.index("Configure the enabled macOS runner")) + self.assertLess(names.index("Leave an existing matching registration untouched"), names.index("Configure the enabled macOS runner")) + assertion = str(self.entries["Refuse an upgrade or unrecognised registration"]) + self.assertIn("existing_version.rc == 0", assertion) + self.assertIn("existing_version.stdout | trim ==", assertion) + argv = self.install["Configure the macOS runner"]["ansible.builtin.command"]["argv"] + self.assertNotIn("--replace", argv) + + def test_busy_or_unknown_service_and_disable_fail_before_changes(self) -> None: + self.assertIn("service.rc in [0, 113]", str(self.entries["Refuse unknown launchd service state"])) + self.assertIn("service.rc == 113", str(self.entries["Refuse an existing launchd service"])) + disabled = str(self.entries["Refuse disabling an existing runner"]) + self.assertIn("directory.stat.exists", disabled) + self.assertIn("registration.stat.exists", disabled) + self.assertIn("service.rc == 113", disabled) + self.assertNotIn("bootout", str(self.tasks)) + handlers = yaml.safe_load((ROLE / "handlers" / "main.yml").read_text()) + self.assertNotIn("bootout", str(handlers)) + + def test_first_install_check_mode_does_not_install_or_register(self) -> None: for name in ("Download the pinned Apple Silicon runner package", "Install the pinned Apple Silicon runner package", "Configure the macOS runner"): - self.assertIn("not ansible_check_mode", entries[name]["when"]) + self.assertIn("not ansible_check_mode", self.install[name]["when"]) + self.assertTrue(self.install["Register the runner through the GitHub App"]["check_mode"] is False) - def test_launchd_restart_listeners_are_real_handlers(self) -> None: + def test_launchd_start_is_a_real_handler(self) -> None: handlers = yaml.safe_load((ROLE / "handlers" / "main.yml").read_text()) listeners = [handler for handler in handlers if handler.get("listen") == "Restart macOS Actions runner"] - self.assertEqual(len(listeners), 2) - self.assertTrue(all("ansible.builtin.command" in handler for handler in listeners)) + self.assertEqual(len(listeners), 1) + self.assertIn("ansible.builtin.command", listeners[0]) if __name__ == "__main__": From dfad8f1385c9b6024cd205605dc622cb9908d5ba Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 5 Nov 2026 22:21:31 +0000 Subject: [PATCH 2/2] fix: publish staged macOS runners exclusively --- .../files/promote.py | 22 ++++ .../tasks/main.yml | 117 +++++++++--------- tests/unit/test_macos_actions_runner_check.py | 31 ++--- tests/unit/test_macos_runner_promotion.py | 80 ++++++++++++ 4 files changed, 167 insertions(+), 83 deletions(-) create mode 100644 roles/github_runner_macos_actions_runner/files/promote.py create mode 100644 tests/unit/test_macos_runner_promotion.py diff --git a/roles/github_runner_macos_actions_runner/files/promote.py b/roles/github_runner_macos_actions_runner/files/promote.py new file mode 100644 index 00000000..ff825304 --- /dev/null +++ b/roles/github_runner_macos_actions_runner/files/promote.py @@ -0,0 +1,22 @@ +#!/usr/bin/python3 +"""Atomically publish a staged runner without replacing an existing path.""" + +import ctypes +import os +import sys + + +def promote(source: str, destination: str) -> None: + """Rename a directory exclusively on Darwin, raising on any failure.""" + libc = ctypes.CDLL(None, use_errno=True) + rename = libc.renamex_np + rename.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint] + rename.restype = ctypes.c_int + # Darwin's RENAME_EXCL refuses an existing destination, including empty directories. + if rename(os.fsencode(source), os.fsencode(destination), 0x00000004) != 0: + error = ctypes.get_errno() + raise OSError(error, os.strerror(error), destination) + + +if __name__ == "__main__": + promote(sys.argv[1], sys.argv[2]) diff --git a/roles/github_runner_macos_actions_runner/tasks/main.yml b/roles/github_runner_macos_actions_runner/tasks/main.yml index 590c901c..cbd72bde 100644 --- a/roles/github_runner_macos_actions_runner/tasks/main.yml +++ b/roles/github_runner_macos_actions_runner/tasks/main.yml @@ -54,27 +54,11 @@ ansible.builtin.meta: end_role when: not (github_runner_macos_actions_runner_enabled | bool) -- name: Check the registered runner version - become: true - ansible.builtin.command: - argv: ["{{ github_runner_macos_actions_runner_root }}/bin/Runner.Listener", --version] - register: github_runner_macos_actions_runner_existing_version - changed_when: false - failed_when: false - check_mode: false - when: github_runner_macos_actions_runner_registration.stat.exists - -- name: Refuse an upgrade or unrecognised registration +- name: Refuse an existing runner registration ansible.builtin.assert: that: - - github_runner_macos_actions_runner_existing_version.rc == 0 - - github_runner_macos_actions_runner_existing_version.stdout | trim == github_runner_macos_actions_runner_version - fail_msg: Upgrading or replacing a registered macOS runner is unsupported until a race-free mechanism exists. - when: github_runner_macos_actions_runner_registration.stat.exists - -- name: Leave an existing matching registration untouched - ansible.builtin.meta: end_role - when: github_runner_macos_actions_runner_registration.stat.exists + - not github_runner_macos_actions_runner_registration.stat.exists + fail_msg: An existing macOS runner registration cannot be safely replaced; leaving it untouched. - name: Refuse an existing launchd service ansible.builtin.assert: @@ -82,28 +66,11 @@ - github_runner_macos_actions_runner_service.rc == 113 fail_msg: An existing macOS runner service cannot be safely replaced. -- name: Require a real runner directory +- name: Refuse any existing runner path ansible.builtin.assert: that: - - not github_runner_macos_actions_runner_directory.stat.exists or (github_runner_macos_actions_runner_directory.stat.isdir and not github_runner_macos_actions_runner_directory.stat.islnk) - fail_msg: The runner path is not a real directory; leaving it untouched. - -- name: Inspect the existing runner directory contents - become: true - ansible.builtin.find: - paths: "{{ github_runner_macos_actions_runner_root }}" - hidden: true - file_type: any - recurse: false - register: github_runner_macos_actions_runner_contents - when: github_runner_macos_actions_runner_directory.stat.exists - -- name: Refuse a non-empty runner directory - ansible.builtin.assert: - that: - - github_runner_macos_actions_runner_contents.matched == 0 - fail_msg: The runner directory is not empty; leaving its installation and workspace untouched. - when: github_runner_macos_actions_runner_directory.stat.exists + - not github_runner_macos_actions_runner_directory.stat.exists + fail_msg: The runner path already exists; leaving it untouched. - name: Read the macOS runner App key from 1Password delegate_to: localhost @@ -181,33 +148,61 @@ quiet: true when: not ansible_check_mode - - name: Create the runner directory + - name: Create the runner parent directory become: true ansible.builtin.file: - path: "{{ github_runner_macos_actions_runner_root }}" + path: "{{ github_runner_macos_actions_runner_root | dirname }}" state: directory - owner: "{{ github_runner_macos_actions_runner_user }}" - mode: "0750" + mode: "0755" - - 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: - - not ansible_check_mode + - name: Stage and publish the first runner installation + when: not ansible_check_mode + block: + - name: Create a private staging directory + become: true + ansible.builtin.tempfile: + state: directory + path: "{{ github_runner_macos_actions_runner_root | dirname }}" + prefix: .github-runner-stage- + register: github_runner_macos_actions_runner_stage - - 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: - - not ansible_check_mode + - 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_stage.path }}/runner.tar.gz" + checksum: "sha256:{{ github_runner_macos_actions_runner_sha256 }}" + mode: "0600" + + - name: Create the staged runner directory + become: true + ansible.builtin.file: + path: "{{ github_runner_macos_actions_runner_stage.path }}/runner" + state: directory + owner: "{{ github_runner_macos_actions_runner_user }}" + mode: "0750" + + - name: Install the pinned Apple Silicon runner package + become: true + ansible.builtin.unarchive: + src: "{{ github_runner_macos_actions_runner_stage.path }}/runner.tar.gz" + dest: "{{ github_runner_macos_actions_runner_stage.path }}/runner" + remote_src: true + owner: "{{ github_runner_macos_actions_runner_user }}" + + - name: Atomically publish the runner without replacing a path + become: true + ansible.builtin.script: + cmd: "{{ role_path }}/files/promote.py {{ (github_runner_macos_actions_runner_stage.path ~ '/runner') | quote }} {{ github_runner_macos_actions_runner_root | quote }}" + executable: /usr/bin/python3 + changed_when: true + always: + - name: Remove the private staging directory + become: true + ansible.builtin.file: + path: "{{ github_runner_macos_actions_runner_stage.path }}" + state: absent + when: github_runner_macos_actions_runner_stage.path is defined - name: Register the runner through the GitHub App delegate_to: localhost diff --git a/tests/unit/test_macos_actions_runner_check.py b/tests/unit/test_macos_actions_runner_check.py index 2cfddd26..94433c06 100644 --- a/tests/unit/test_macos_actions_runner_check.py +++ b/tests/unit/test_macos_actions_runner_check.py @@ -16,25 +16,12 @@ def setUp(self) -> None: enabled = self.entries["Configure the enabled macOS runner"] self.install = {task["name"]: task for task in enabled["block"]} - def test_empty_root_owned_directory_is_populated_without_removal(self) -> None: - self.assertTrue(self.entries["Inspect the existing runner directory contents"]["become"]) - find = self.entries["Inspect the existing runner directory contents"]["ansible.builtin.find"] - self.assertTrue(find["hidden"]) - self.assertEqual(find["file_type"], "any") - self.assertIn("matched == 0", str(self.entries["Refuse a non-empty runner directory"])) - self.assertTrue(self.install["Install the pinned Apple Silicon runner package"]["become"]) - self.assertNotIn("state: absent", str(self.install["Create the runner directory"])) - self.assertNotIn("Remove the prior runner installation", self.install) - - def test_registration_is_checked_before_changes_and_matching_install_is_untouched(self) -> None: + def test_existing_destination_and_registration_are_refused_before_install(self) -> None: names = list(self.entries) - self.assertLess(names.index("Refuse an upgrade or unrecognised registration"), names.index("Configure the enabled macOS runner")) - self.assertLess(names.index("Leave an existing matching registration untouched"), names.index("Configure the enabled macOS runner")) - assertion = str(self.entries["Refuse an upgrade or unrecognised registration"]) - self.assertIn("existing_version.rc == 0", assertion) - self.assertIn("existing_version.stdout | trim ==", assertion) - argv = self.install["Configure the macOS runner"]["ansible.builtin.command"]["argv"] - self.assertNotIn("--replace", argv) + for name in ("Refuse any existing runner path", "Refuse an existing runner registration"): + self.assertLess(names.index(name), names.index("Configure the enabled macOS runner")) + self.assertIn("not github_runner_macos_actions_runner_directory.stat.exists", str(self.entries["Refuse any existing runner path"])) + self.assertNotIn("--replace", self.install["Configure the macOS runner"]["ansible.builtin.command"]["argv"]) def test_busy_or_unknown_service_and_disable_fail_before_changes(self) -> None: self.assertIn("service.rc in [0, 113]", str(self.entries["Refuse unknown launchd service state"])) @@ -47,10 +34,10 @@ def test_busy_or_unknown_service_and_disable_fail_before_changes(self) -> None: handlers = yaml.safe_load((ROLE / "handlers" / "main.yml").read_text()) self.assertNotIn("bootout", str(handlers)) - def test_first_install_check_mode_does_not_install_or_register(self) -> None: - for name in ("Download the pinned Apple Silicon runner package", "Install the pinned Apple Silicon runner package", "Configure the macOS runner"): - self.assertIn("not ansible_check_mode", self.install[name]["when"]) - self.assertTrue(self.install["Register the runner through the GitHub App"]["check_mode"] is False) + def test_first_install_check_mode_skips_staging(self) -> None: + stage = self.install["Stage and publish the first runner installation"] + self.assertEqual(stage["when"], "not ansible_check_mode") + self.assertIn("not ansible_check_mode", self.install["Configure the macOS runner"]["when"]) def test_launchd_start_is_a_real_handler(self) -> None: handlers = yaml.safe_load((ROLE / "handlers" / "main.yml").read_text()) diff --git a/tests/unit/test_macos_runner_promotion.py b/tests/unit/test_macos_runner_promotion.py new file mode 100644 index 00000000..9f16a610 --- /dev/null +++ b/tests/unit/test_macos_runner_promotion.py @@ -0,0 +1,80 @@ +"""Exercise Darwin exclusive runner promotion on disposable local directories.""" + +import concurrent.futures +import importlib.util +from pathlib import Path +import sys +import tempfile +import unittest + +ROLE = Path(__file__).resolve().parents[2] / "roles" / "github_runner_macos_actions_runner" +SPEC = importlib.util.spec_from_file_location("runner_promotion", ROLE / "files" / "promote.py") +assert SPEC is not None and SPEC.loader is not None +PROMOTION = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(PROMOTION) + + +@unittest.skipUnless(sys.platform == "darwin", "Darwin renamex_np is required") +class PromotionTest(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix="runner-promotion-test-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.destination = self.root / "runner" + + def stage(self, name: str) -> Path: + directory = self.root / name + directory.mkdir() + (directory / "content").write_text(name) + return directory + + def test_absent_destination_is_installed(self) -> None: + source = self.stage("first") + PROMOTION.promote(str(source), str(self.destination)) + self.assertEqual((self.destination / "content").read_text(), "first") + self.assertFalse(source.exists()) + + def test_existing_empty_and_nonempty_destinations_are_unchanged(self) -> None: + for existing in (False, True): + with self.subTest(nonempty=existing): + self.destination.mkdir() + if existing: + (self.destination / ".runner").write_text("registered") + source = self.stage("stage") + with self.assertRaises(OSError): + PROMOTION.promote(str(source), str(self.destination)) + self.assertEqual(list(self.destination.iterdir()), [self.destination / ".runner"] if existing else []) + self.assertTrue(source.exists()) + (source / "content").unlink() + source.rmdir() + if existing: + (self.destination / ".runner").unlink() + self.destination.rmdir() + + def test_concurrent_installers_have_one_winner(self) -> None: + stages = [self.stage("first"), self.stage("second")] + + def attempt(source: Path) -> bool: + try: + PROMOTION.promote(str(source), str(self.destination)) + return True + except FileExistsError: + return False + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(attempt, stages)) + self.assertEqual(sum(results), 1) + self.assertIn((self.destination / "content").read_text(), {"first", "second"}) + self.assertEqual(sum(source.exists() for source in stages), 1) + + def test_interrupted_staging_does_not_create_destination(self) -> None: + self.stage("interrupted") + self.assertFalse(self.destination.exists()) + source = self.stage("retry") + PROMOTION.promote(str(source), str(self.destination)) + self.assertEqual((self.destination / "content").read_text(), "retry") + self.assertTrue((self.root / "interrupted" / "content").exists()) + + +if __name__ == "__main__": + unittest.main()