├── .github ├── actions │ └── config │ │ ├── ansible.yaml │ │ └── yamllint.yaml └── workflows │ └── ci.yaml ├── .gitignore ├── LICENSE ├── README.md ├── defaults └── main.yaml ├── includes ├── download.yaml ├── install.yaml └── uninstall.yaml ├── meta └── main.yaml └── tasks └── main.yaml /.github/actions/config/ansible.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | skip_list: 3 | # YAML lint is executed apart from Ansible lint to support custom 4 | # configurations 5 | - yaml 6 | - role-name 7 | -------------------------------------------------------------------------------- /.github/actions/config/yamllint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | rules: 3 | braces: 4 | min-spaces-inside: 0 5 | max-spaces-inside: 0 6 | min-spaces-inside-empty: -1 7 | max-spaces-inside-empty: -1 8 | brackets: 9 | min-spaces-inside: 0 10 | max-spaces-inside: 0 11 | min-spaces-inside-empty: -1 12 | max-spaces-inside-empty: -1 13 | colons: 14 | max-spaces-before: 0 15 | max-spaces-after: 1 16 | commas: 17 | max-spaces-before: 0 18 | min-spaces-after: 1 19 | max-spaces-after: 1 20 | comments: 21 | require-starting-space: yes 22 | min-spaces-from-content: 1 23 | document-end: disable 24 | document-start: enable 25 | empty-lines: 26 | max: 1 27 | max-start: 0 28 | max-end: 0 29 | hyphens: 30 | max-spaces-after: 1 31 | indentation: 32 | spaces: consistent 33 | indent-sequences: consistent 34 | check-multi-line-strings: no 35 | # key-duplicates: enable 36 | line-length: 37 | max: 120 38 | allow-non-breakable-words: yes 39 | new-line-at-end-of-file: enable 40 | new-lines: 41 | type: unix 42 | # trailing-spaces: disable 43 | truthy: 44 | allowed-values: 45 | - !!str yes 46 | - !!str true 47 | - !!str no 48 | - !!str false 49 | level: warning 50 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Continuous Integration 3 | 4 | "on": # Ref: https://github.com/adrienverge/yamllint/issues/430#issuecomment-1107440224 5 | push: 6 | branches: ["**"] 7 | pull_request: 8 | branches: [master] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: 17 | - "3.8" 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Enable cache for (pip) dependencies 28 | uses: actions/cache@v2 29 | with: 30 | path: ~/.cache/pip 31 | key: ${{ runner.os }}-pip 32 | restore-keys: | 33 | ${{ runner.os }}-pip- 34 | 35 | - name: Lint | Check Ansible and YAML 36 | run: | 37 | python -m pip install --upgrade pip 38 | python -m pip install yamllint ansible ansible-lint 39 | 40 | yamllint -c .github/actions/config/yamllint.yaml . 41 | ansible-lint -c .github/actions/config/ansible.yaml . 42 | 43 | - name: Galaxy | Import 44 | if: ${{ github.ref == 'refs/heads/master' }} 45 | run: | 46 | ansible-galaxy role import \ 47 | --api-key ${ANSIBLE_GALAXY_API_KEY} \ 48 | --branch master \ 49 | macunha1 github_actions_runner 50 | env: 51 | ANSIBLE_GALAXY_API_KEY: ${{ secrets.ANSIBLE_GALAXY_API_KEY }} 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDEs 2 | .idea/ 3 | .vscode/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright © 2021, Matheus Cunha 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the “Software”), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

GitHub Actions self-hosted Runner Ansible role

2 | 3 | An Ansible role that installs and configures GitHub Actions self-hosted Runners 4 | inside one or multiple hosts, you can re-use it for many different URLs 5 | (repositories or organizations) inside the same host in order to re-use it as 6 | much as possible. 7 | 8 | Main goals of this role: 9 | 10 | - _avoid waste_: re-use the same host to provide a build environment for 11 | multiple repositories or organizations; 12 | - _idempotence_: executing the role many times won't make anything break, steps 13 | have checks that validate whether or not they should be executed; 14 | 15 | ## Variables 16 | 17 | For an exhaustive list of variables check the [defaults](defaults/main.yaml) 18 | file. Ideally, all values will have commentaries describing what are their 19 | purposes and by the default value you can tell the type. 20 | 21 | ### Required variables 22 | 23 | Following values are required since there is no way to register the self-hosted 24 | Runner without them 25 | 26 | | Name | Description | 27 | | ---------------------- | -------------------------------------------------- | 28 | | gh_runner_config_url | GitHub Repository or Organization URL | 29 | | gh_runner_config_token | GitHub Registration token to authenticate the host | 30 | 31 | ## Example Playbook 32 | 33 | Simplest use case: Single repository configuration on one host. 34 | 35 | ```yaml 36 | - hosts: foo 37 | roles: 38 | - role: macunha1.github_actions_runner 39 | vars: 40 | gh_runner_config_labels: 41 | - linux 42 | - self-hosted 43 | 44 | gh_runner_config_url: https://github.com/macunha1/ansible-github-actions-runner 45 | gh_runner_config_token: AC5TNLJP9SBAFNEKKLLBLF264J8XO 46 | ``` 47 | 48 | Complex use case to which this role was created for 49 | 50 | ```yaml 51 | - hosts: foo 52 | roles: 53 | - role: macunha1.github_actions_runner 54 | vars: 55 | gh_runner_config_labels: 56 | - linux 57 | - self-hosted 58 | 59 | gh_runner_config_url: https://github.com/macunha1/ansible-github-actions-runner 60 | gh_runner_config_token: AC5TNLJP9SBAFNEKKLLBLF264J8XO 61 | 62 | - role: macunha1.github_actions_runner 63 | vars: 64 | gh_runner_config_url: https://github.com/macunha1/another-repository 65 | gh_runner_config_token: AC5CQV3IJRR2OAFGEFCPJ0WJPJQXO 66 | 67 | - role: macunha1.github_actions_runner 68 | vars: 69 | gh_runner_config_url: https://github.com/macunha-acme-corp 70 | gh_runner_config_token: ACYWUR9MHGR9U58C34W9ZK00UNBF 71 | ``` 72 | 73 | Note that despite using the same host, each one of these GitHub Actions Runner 74 | configuration will have its own path and credentials. Therefore, they can live 75 | well in harmony without killing each other. 76 | 77 | ## Contribute 78 | 79 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) 80 | 81 | Feel free to fill [an issue](https://github.com/macunha1/ansible-github-actions-runner/issues) 82 | containing feature request(s), or (even better) to send me a Pull request, I 83 | would be happy to collaborate with you. 84 | 85 | If this role didn't work for you, or if you found some bug during the execution, 86 | let me know. 87 | -------------------------------------------------------------------------------- /defaults/main.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Temporary path to download the GitHub Actions runner archive 3 | tmp_download_path: /tmp 4 | 5 | # Whether or not to clean the downloaded package in the temporary path after 6 | # unpacking/installing it. 7 | tmp_clear_download: true 8 | 9 | # Base URL to fetch the GitHub Actions Runner package with scripts that is used 10 | # to install and configure the host. 11 | gh_runner_download_base_url: https://github.com/actions/runner/releases/download 12 | 13 | # GitHub Actions runner version to download and install. Multiple parallel 14 | # versions are supported since every installation+configuration (either 15 | # repository or organization) has its own path. 16 | gh_runner_version: 2.290.1 17 | 18 | # GitHub Actions runner architecture, used together with the version to compose 19 | # the complete URL that is used to download the package. 20 | gh_runner_architecture: linux-x64 21 | 22 | # Path to unarchive the GitHub Actions runner package, this will be the prefix 23 | # with multiple versions inside. Allowing to share the same host with many 24 | # self-hosted runners regardless of the version. 25 | gh_runner_installation_path: /usr/local/share/github-actions-runner 26 | 27 | # GitHub Actions runner workspace, i.e. path used by job executions to checkout 28 | # the code, write files from the workflow, and etc. 29 | gh_runner_workspace_path: /var/cache/github-actions-runner 30 | 31 | # Whether or not to remove the GitHub Actions runner, enable to clean and 32 | # deregister the host. Enable this to remove the host from GitHub Actions and 33 | # complement with the --tag uninstall 34 | gh_runner_remove_host: false 35 | 36 | # Run GitHub Actions service will be installed to run as this user. 37 | gh_runner_service_user: "{{ ansible_user_id }}" 38 | 39 | ## Variables used to set parameters to the `config.sh` script. 40 | 41 | # (REQUIRED) GitHub Actions repository URL to register this self-hosted runner. 42 | # gh_runner_config_url: "" 43 | 44 | # (REQUIRED) GitHub Actions self-hosted Runner registration token, used to 45 | # authenticate the host to GitHub (either to a GitHub repository or to an GitHub 46 | # organization). Keep this value secure, e.g. using Ansible Vault. 47 | # Ref: https://docs.github.com/en/actions/hosting-your-own-runners/adding-self-hosted-runners 48 | # gh_runner_config_token: "" 49 | 50 | # Define the Github Actions Runner tags, this list will be converted to a 51 | # comma-separated string 52 | gh_runner_config_labels: ["self-hosted"] 53 | 54 | # Set the name to register the runner as in GitHub. 55 | gh_runner_config_name: "{{ ansible_hostname }}" 56 | 57 | # Set the runner group to add the runner to. If left blank, the default runner group will be used. 58 | gh_runner_config_runnergroup: "" 59 | 60 | # Allow/Refuse to configure the runner as root. If left blank, the root user is not allowed to configure the runner. 61 | gh_runner_allow_runasroot: "" 62 | -------------------------------------------------------------------------------- /includes/download.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: GitHub Actions Runner | Create workspace 3 | ansible.builtin.file: 4 | path: "{{ gh_runner_workspace_path }}" 5 | owner: "{{ gh_runner_service_user }}" 6 | state: directory 7 | mode: 0755 8 | become: true 9 | 10 | - name: GitHub Actions Runner | Check if versions is already installed 11 | ansible.builtin.stat: 12 | path: "{{ gh_runner_path }}" 13 | register: unarchived_package 14 | 15 | # Download the package only if it isn't found in the installed versions 16 | - name: GitHub Actions Runner | Download package if not already done 17 | when: not unarchived_package.stat.exists 18 | block: 19 | - name: GitHub Actions Runner | Downloading package {{ gh_runner_version }} 20 | ansible.builtin.get_url: 21 | # yamllint disable-line rule:line-length 22 | url: "{{ gh_runner_download_base_url }}/v{{ gh_runner_version }}/actions-runner-{{ gh_runner_architecture }}-{{ gh_runner_version }}.tar.gz" 23 | dest: "{{ tmp_download_path }}/actions-runner-{{ gh_runner_version }}.tar.gz" 24 | mode: 0400 25 | force: yes 26 | become: true 27 | 28 | - name: GitHub Actions Runner | Create installation directory 29 | ansible.builtin.file: 30 | path: "{{ gh_runner_path }}" 31 | state: directory 32 | mode: 0755 33 | become: true 34 | 35 | - name: GitHub Actions Runner | Unarchive package 36 | ansible.builtin.unarchive: 37 | src: "{{ tmp_download_path }}/actions-runner-{{ gh_runner_version }}.tar.gz" 38 | dest: "{{ gh_runner_path }}" 39 | remote_src: true 40 | register: gh_runner_path_unarchived 41 | become: true 42 | 43 | - name: GitHub Actions Runner | Set permissions 44 | ansible.builtin.file: 45 | path: "{{ gh_runner_path }}" 46 | owner: "{{ gh_runner_service_user }}" 47 | mode: 0755 48 | recurse: true 49 | become: true 50 | 51 | # Remove the package after extracting the content IF tmp_clear_download is 52 | # enabled (default: true). 53 | - name: GitHub Actions Runner | Remove archived package 54 | ansible.builtin.file: 55 | path: "{{ tmp_download_path }}/actions-runner-{{ gh_runner_version }}.tar.gz" 56 | state: absent 57 | become: true 58 | when: tmp_clear_download 59 | -------------------------------------------------------------------------------- /includes/install.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: GitHub Actions Runner | Install dependencies 3 | ansible.builtin.command: 4 | cmd: "{{ gh_runner_path }}/bin/installdependencies.sh" 5 | chdir: "{{ gh_runner_path }}" 6 | become: true 7 | when: 8 | # Only install dependencies if the installation directory contains changes, 9 | # which is indicated in this case by the `unarchive` task. 10 | - gh_runner_path_unarchived is defined 11 | - gh_runner_path_unarchived.changed 12 | - ansible_os_family != 'Darwin' 13 | tags: 14 | - install 15 | 16 | # Check if the registration token was used before. config.sh executions aren't 17 | # idempotent, if called twice with the very same token and URL it will fail 18 | # asking to remove the host before configuring it. 19 | # 20 | # ["Cannot configure the runner because it is already configured. To 21 | # reconfigure the runner, run 'config.cmd remove' or './config.sh remove' first."] 22 | - name: GitHub Actions Runner | Check if this host URL+token was already registered 23 | ansible.builtin.stat: 24 | path: "{{ gh_runner_path }}/hosts/{{ gh_runner_config_token | hash('sha256') }}" 25 | register: is_this_host_token_registered 26 | tags: 27 | - configure 28 | 29 | - name: GitHub Actions Runner | Search for abandoned credentials 30 | ansible.builtin.stat: 31 | path: "{{ gh_runner_path }}/.credentials" 32 | register: abandoned_credentials 33 | 34 | # Search only if the host is not registered, which is a clear indicator that 35 | # any credentials inside that path are from previous executions or expired 36 | # tokens, since the registration token hash is not matching any state file. 37 | when: not is_this_host_token_registered.stat.exists 38 | 39 | # If abandoned credentials are found AND this host's CURRENT token is not registered, we 40 | # need to uninstall its service + remove it before proceeding, otherwise the 41 | # `config.sh` command to register the node will fail asking to remove it (which 42 | # we are proactively doing here). 43 | - name: GitHub Actions Runner | Pro-activelly uninstall before re-installing 44 | 45 | when: 46 | - not is_this_host_token_registered.stat.exists 47 | - abandoned_credentials is defined 48 | - abandoned_credentials.stat.exists 49 | 50 | tags: 51 | - configure 52 | 53 | block: 54 | - name: GitHub Actions Runner | Uninstall previous service 55 | ansible.builtin.command: 56 | cmd: "{{ gh_runner_path }}/svc.sh uninstall" 57 | chdir: "{{ gh_runner_path }}" 58 | changed_when: abandoned_credentials.stat.exists 59 | become: "{{ ansible_os_family != 'Darwin' }}" 60 | 61 | - name: GitHub Actions Runner | Remove previous configuration before proceeding 62 | become: true 63 | become_user: "{{ gh_runner_service_user }}" 64 | environment: 65 | RUNNER_ALLOW_RUNASROOT: "{{ gh_runner_allow_runasroot }}" 66 | ansible.builtin.command: 67 | cmd: |- 68 | {{ gh_runner_path }}/config.sh remove \ 69 | --token {{ gh_runner_config_token }} 70 | chdir: "{{ gh_runner_path }}" 71 | changed_when: abandoned_credentials.stat.exists 72 | 73 | - name: GitHub Actions Runner | Configure Runner 74 | become: true 75 | become_user: "{{ gh_runner_service_user }}" 76 | environment: 77 | RUNNER_ALLOW_RUNASROOT: "{{ gh_runner_allow_runasroot }}" 78 | ansible.builtin.command: 79 | cmd: |- 80 | {{ gh_runner_path }}/config.sh \ 81 | --unattended --replace --disableupdate \ 82 | --url {{ gh_runner_config_url }} \ 83 | --token {{ gh_runner_config_token }} \ 84 | --name {{ gh_runner_config_name }} \ 85 | --runnergroup "{{ gh_runner_config_runnergroup }}" \ 86 | --work {{ gh_runner_workspace_path }} \ 87 | --labels {{ gh_runner_config_labels | join(',') }} 88 | chdir: "{{ gh_runner_path }}" 89 | register: config_host_command 90 | 91 | when: not is_this_host_token_registered.stat.exists 92 | tags: 93 | - configure 94 | 95 | - name: GitHub Actions Runner | Check if path for state files exists 96 | ansible.builtin.stat: 97 | path: "{{ gh_runner_path }}/hosts" 98 | register: registered_hosts_path 99 | tags: 100 | - configure 101 | 102 | - name: GitHub Actions Runner | Create hosts directory 103 | ansible.builtin.file: 104 | path: "{{ gh_runner_path }}/hosts" 105 | state: directory 106 | owner: "{{ gh_runner_service_user }}" 107 | mode: 0755 108 | become: true 109 | when: not registered_hosts_path.stat.exists 110 | tags: 111 | - configure 112 | 113 | # Create a state file to notify next Ansible executions that a given 114 | # registration token was already used. i.e. the is_this_host_token_registered 115 | # variable registered and used in the credential above. 116 | - name: GitHub Actions Runner | Create state file 117 | ansible.builtin.file: 118 | path: "{{ gh_runner_path }}/hosts/{{ gh_runner_config_token | hash('sha256') }}" 119 | state: touch 120 | mode: 0400 121 | when: 122 | - config_host_command is defined 123 | - config_host_command is success 124 | become: true 125 | tags: 126 | - configure 127 | 128 | - name: GitHub Actions Runner | Install service 129 | ansible.builtin.command: 130 | cmd: "{{ gh_runner_path }}/svc.sh install {{ gh_runner_service_user }}" 131 | chdir: "{{ gh_runner_path }}" 132 | become: "{{ ansible_os_family != 'Darwin' }}" 133 | when: not is_this_host_token_registered.stat.exists 134 | tags: 135 | - install 136 | 137 | - name: GitHub Actions Runner | Check service status 138 | ansible.builtin.command: 139 | cmd: "{{ gh_runner_path }}/svc.sh status" 140 | chdir: "{{ gh_runner_path }}" 141 | register: gh_runner_service_status 142 | changed_when: false # never changes, this is just a read-only command 143 | become: "{{ ansible_os_family != 'Darwin' }}" 144 | tags: 145 | - configure 146 | 147 | - name: GitHub Actions Runner | Start service 148 | ansible.builtin.command: 149 | cmd: "{{ gh_runner_path }}/svc.sh start" 150 | chdir: "{{ gh_runner_path }}" 151 | when: not '"active (running)"' in gh_runner_service_status.stdout 152 | become: "{{ ansible_os_family != 'Darwin' }}" 153 | tags: 154 | - configure 155 | -------------------------------------------------------------------------------- /includes/uninstall.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: GitHub Actions Runner [!!] DESTROY! | Uninstall service 3 | ansible.builtin.command: 4 | cmd: "{{ gh_runner_path }}/svc.sh uninstall" 5 | chdir: "{{ gh_runner_path }}" 6 | changed_when: gh_runner_remove_host 7 | become: "{{ ansible_os_family != 'Darwin' }}" 8 | 9 | - name: GitHub Actions Runner [!!] DESTROY! | De-register Runner 10 | ansible.builtin.command: 11 | cmd: |- 12 | {{ gh_runner_path }}/config.sh remove \ 13 | --token "{{ gh_runner_config_token }}" 14 | chdir: "{{ gh_runner_path }}" 15 | changed_when: gh_runner_remove_host 16 | 17 | - name: GitHub Actions Runner [!!] DESTROY! | Delete workspace and installations 18 | ansible.builtin.file: 19 | state: absent 20 | path: "{{ item }}" 21 | with_items: 22 | - "{{ gh_runner_installation_path }}/" 23 | - "{{ gh_runner_workspace_path }}/" 24 | changed_when: gh_runner_remove_host 25 | become: true 26 | -------------------------------------------------------------------------------- /meta/main.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | galaxy_info: 3 | role_name: github_actions_runner 4 | author: Matheus Cunha 5 | description: > 6 | Idempotent Ansible role that installs and configures self-hosted GitHub 7 | Actions Runners (yeah, plural!) 8 | license: MIT 9 | company: None 10 | min_ansible_version: "2.2" 11 | platforms: 12 | - name: Debian 13 | versions: 14 | - all 15 | - name: EL 16 | versions: 17 | - all 18 | - name: Fedora 19 | versions: 20 | - all 21 | - name: Ubuntu 22 | versions: 23 | - all 24 | galaxy_tags: 25 | - github 26 | - actions 27 | - workflows 28 | - runner 29 | -------------------------------------------------------------------------------- /tasks/main.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Variables | Check mandatory 3 | ansible.builtin.assert: 4 | that: 5 | - gh_runner_config_url is defined 6 | - gh_runner_config_token is defined 7 | 8 | - name: Variables | Set installation path 9 | ansible.builtin.set_fact: 10 | # Set a exclusive path for the GitHub Actions Runner in order to support 11 | # multiple repository or organizations configured inside the same host. 12 | # 13 | # WHY? `config.sh` when registering the host will write some credentials and 14 | # files to the path in order to identify the host, although inoffensive, 15 | # this approach make it complicated to share the same host among multiple 16 | # GitHub Actions Runners (even though it is possible). 17 | # 18 | # Therefore, each GitHub repository or organization URL will be hashed to 19 | # compose the GitHub Actions Runner path. 20 | # 21 | # yamllint disable-line rule:line-length 22 | gh_runner_path: "{{ gh_runner_installation_path }}/{{ gh_runner_version }}/{{ gh_runner_config_url | hash('sha256') }}" 23 | 24 | tags: 25 | - install 26 | - configure 27 | - uninstall 28 | 29 | - name: GitHub Actions Runner | Import Download tasks 30 | ansible.builtin.import_tasks: "../includes/download.yaml" 31 | tags: 32 | - install 33 | 34 | - name: GitHub Actions Runner | Import Install tasks 35 | ansible.builtin.import_tasks: "../includes/install.yaml" 36 | tags: 37 | - install 38 | - configure 39 | 40 | - name: GitHub Actions Runner | Include Uninstall tasks 41 | ansible.builtin.include_tasks: "../includes/uninstall.yaml" 42 | when: gh_runner_remove_host 43 | tags: 44 | - uninstall 45 | --------------------------------------------------------------------------------