diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..26c05db --- /dev/null +++ b/.editorconfig @@ -0,0 +1,39 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +# Matches multiple files with brace expansion notation +# Set default charset +[*.{sh,yml,md,json}] +charset = utf-8 + +# 2 space indentation +[*.{yml,json}] +indent_style = space +indent_size = 2 + +# 4 space indentation +[*.{md}] +indent_style = space +indent_size = 4 + +# 4 tab indentation +[*.{sh}] +indent_style = tab +indent_size = 4 + +# 2 space indentation // matches the exact file +[{.gitignore}] +indent_style = space +indent_size = 2 + +# 4 tab indentation // matches the exact file +[{Makefile,Dockerfile}] +indent_style = tab +indent_size = 4 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..c709c75 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: Cyclenerd diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..ebd87a7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Create a report to help us improve +title: 'Bug: Good title' +labels: 'bug' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..9117466 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Contact me on Mastodon + url: https://fosstodon.org/@cyclenerd + about: Feel free to follow me on Mastodon and send me a message \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..0b5fa1e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: 'Feature request: Good title' +labels: 'enhancement' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ba54936 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,9 @@ +First off, thanks for taking the time to contribute! + +## Please Complete the Following + +- [ ] I read [CONTRIBUTING.md](https://github.com/Cyclenerd/hcloud-github-runner/blob/master/CONTRIBUTING.md) + +## Notes + +Feel free to put whatever you want here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5ace460 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1189d31 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: "CI" + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + test: + name: CI/CD Test + # https://github.com/actions/virtual-environments/ + runs-on: ubuntu-24.04 + steps: + - name: πŸ›ŽοΈ Checkout + uses: actions/checkout@v4 + - name: 🌑️ Test + run: shellcheck *.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f08773 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +.idea +.vscode +cloud-init.yml +create-server-*.json +create-server.json +github-runners.json +output.json +registration-token.json +servers.json diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..5dbda78 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +nils [at] nkn-it (dot) de. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bb91a59 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing to the Project + +Thank you for considering contributing to our project! Your help and involvement are highly appreciated. +This guide will help you get started with the contribution process. + +## Table of Contents + +1. [Fork the Repository](#fork-the-repository-) +2. [Clone Your Fork](#clone-your-fork-) +3. [Create a New Branch](#create-a-new-branch-) +4. [Submitting Changes](#submitting-changes-) +5. [Create a Pull Request](#create-a-pull-request-) +6. [Coding Style](#coding-style-) +7. [Keep It Simple](#keep-it-simple-) + +## Fork the Repository 🍴 + +Start by forking the repository. You can do this by clicking the "Fork" button in the +upper right corner of the repository page. This will create a copy of the repository +in your GitHub account. + +## Clone Your Fork πŸ“₯ + +Clone your newly created fork of the repository to your local machine with the following command: + +```bash +git clone https://github.com/your-username/hcloud-github-runner.git +``` + +## Create a New Branch 🌿 + +Create a new branch for the specific issue or feature you are working on. +Use a descriptive branch name: + +```bash +git checkout -b "feature-or-issue-name" +``` + +## Submitting Changes πŸš€ +Make your desired changes to the codebase. + +Stage your changes using the following command: + +```bash +git add . +``` + +Commit your changes with a clear and concise commit message: + +```bash +git commit -m "A brief summary of the commit." +``` + +## Create a Pull Request 🌟 + +Go to your forked repository on GitHub and click on the "New Pull Request" button. +This will open a new pull request to the original repository. + +## Coding Style πŸ“ + +Start reading the code, and you'll get the hang of it. It is optimized for readability: + +- Variables must be uppercase and should begin with `MY_`. +- Functions must be lowercase. +- Check your shell scripts with ShellCheck before submitting. +- Please use tabs to indent. + +## Keep It Simple πŸ‘ + +Simplicity is key. When making changes, aim for clean, easy-to-understand code that benefits all users. + +Thank you for your contribution! ❀️ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..792450b --- /dev/null +++ b/README.md @@ -0,0 +1,320 @@ +# Self-Hosted GitHub Actions Runner on Hetzner Cloud + +[![Badge: Hetzer](https://img.shields.io/badge/Hetzner-D50C2D.svg?logo=hetzner&logoColor=white)](#readme) +[![Badge: GitHub](https://img.shields.io/badge/GitHub-181717.svg?logo=github&logoColor=white)](#readme) +[![Badge: Linux](https://img.shields.io/badge/Linux-FCC624.svg?logo=linux&logoColor=black)](#readme) +[![Badge: Debian](https://img.shields.io/badge/Debian-A81D33.svg?logo=debian&logoColor=white)](#readme) +[![Badge: Ubuntu](https://img.shields.io/badge/Ubuntu-E95420.svg?logo=ubuntu&logoColor=white)](#readme) +[![Badge: Fedora](https://img.shields.io/badge/Fedora-51A2DA.svg?logo=fedora&logoColor=white)](#readme) +[![Badge: Rocky Linux](https://img.shields.io/badge/Rocky%20Linux-10B981.svg?logo=rockylinux&logoColor=white)](#readme) +[![Badge: openSUSE](https://img.shields.io/badge/openSUSE-73BA25.svg?logo=opensuse&logoColor=white)](#readme) +[![Badge: GNU Bash](https://img.shields.io/badge/GNU%20Bash-4EAA25.svg?logo=gnubash&logoColor=white)](#readme) +[![Badge: License](https://img.shields.io/github/license/cyclenerd/hcloud-github-runner)](https://github.com/Cyclenerd/hcloud-github-runner/blob/master/LICENSE) + +A GitHub Action to automatically create Hetzner Cloud servers and register them as self-hosted GitHub Actions runners. + +Launch a Hetzner Cloud Server as a self-hosted GitHub Actions Runner just before your job starts. +Execute your workflow, and then automatically terminate the server upon completion. +All within your GitHub Actions workflow. + +This [GitHub Action](./action.sh) is written in Bash (Shell Script). +Everything was carefully documented and kept as simple as possible. +The aim is to enable quick and easy auditability of the code. + +## Use Cases + +This section highlights how using Hetzner Cloud with self-hosted runners for your GitHub Actions CI/CD workflows can lead to significant cost savings and predictable billing compared to relying solely on GitHub-managed runners. + +### Cost Control and Predictability + +> [!IMPORTANT] +> As per [GitHub's documentation](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners), self-hosted runners are free to use with GitHub Actions. +> Therefore, you won't be charged by GitHub for the execution time of jobs on your self-hosted runners. +> You'll only pay Hetzner for the server hours itself, which can be a more economical option than GitHub-hosted runners, particularly for resource-intensive or frequent workflows. +> Hetzner always round up the hourly usage of a server. +> If you create a server just for a few minutes, we will still bill you for one whole hour. ([FAQ](https://docs.hetzner.com/cloud/billing/faq)). + +* **Potentially Lower Costs for High Usage:** For organizations with consistently high CI/CD usage, self-hosting on Hetzner Cloud can be significantly more cost-effective than paying for GitHub Actions minutes, especially for larger jobs or parallel execution. +* **No Usage Limits (Within Server Capacity):** You're not restricted by GitHub Actions usage limits (within the capacity of your Hetzner Cloud Server). This is beneficial for large builds, extensive testing, or frequent deployments. + +The following table provides a comparison of pricing between GitHub-managed Actions runners and Hetzner Cloud with self-hosted runners (information provided without guarantee; prices exclude VAT): + +| Runner | [GitHub](https://docs.github.com/en/billing/managing-billing-for-your-products/managing-billing-for-github-actions/about-billing-for-github-actions) | [Hetzner ](https://www.hetzner.com/cloud/) | Cost Saving | Cost Saving (%) | +|---|---|---|---|---| +| 2 Core (Intel) | $0.48 USD/hr | $0.0074 USD/hr | $0.4726 USD/hr | 98.46 % | +| 4 Core (Intel) | $0.96 USD/hr | $0.0127 USD/hr | $0.9473 USD/hr | 98.68 % | +| 8 Core (Intel) | $1.92 USD/hr | $0.0304 USD/hr | $1.8896 USD/hr | 98.42 % | +| 16 Core (Intel) | $3.84 USD/hr | $0.0611 USD/hr | $3.7789 USD/hr | 98.41 % | +| 2 Core (Arm) | $0.30 USD/hr | $0.0074 USD/hr | $0.2926 USD/hr | 97.53 % | +| 4 Core (Arm) | $0.60 USD/hr | $0.0122 USD/hr | $0.5878 USD/hr | 97.97 % | +| 8 Core (Arm) | $1.20 USD/hr | $0.0226 USD/hr | $1.1774 USD/hr | 98.12 % | +| 16 Core (Arm) | $2.40 USD/hr | $0.0443 USD/hr | $2.3557 USD/hr | 98.15 % | + +### Customization and Control + +* **Custom Hardware and Software:** You have complete control over the hardware (CPU, RAM, storage) and software environment of your runners. This allows you to: + * Instead of the default Ubuntu-based GitHub Actions Runner image, use a specific Linux operating system image. This runner has been tested with Debian (`debian-12`), Ubuntu (`ubuntu-24.04`), Fedora Linux (`fedora-41`), Rocky Linux (`rocky-9`), openSUSE Leap (`opensuse-15`) and custom image (snapshot). + * Install specific dependencies or tools that might not be available on GitHub-managed runners. + * Optimize performance for your specific workloads. +* **ARM architecture:** If you need to build for architectures other than `x86_64` (Intel, AMD), you have full control to set up the appropriate Arm-based `arm64` (Ampere Altra) runner environment. +* **Network Access and Security:** You have more control over network access and security. You can access private resources within your Hetzner Cloud network or other private networks via VPNs. +* **Larger Disk Space:** If your builds require a lot of disk space for dependencies, build artifacts, or large files, you can easily provision servers with larger disks (up to 960 GB) on Hetzner Cloud. GitHub-managed runners have limited disk space. + +## Usage + +Prepare your workflow for Hetzner Cloud self-hosted runners: + +1. **Create a fine-grained GitHub Personal Access Token (PAT)** with "Read and write" access to "Administration" + * [GitHub](https://github.com/settings/personal-access-tokens) β†’ Settings β†’ Developer Settings β†’ Personal access tokens β†’ +Fine-grained personal access tokens + * [More Help](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) +1. **Generate an Hetzner Cloud API token** with "Read & Write" permissions in the Hetzner Cloud Console + * [Hetzner Cloud Console](https://console.hetzner.cloud/) β†’ Select project β†’ Security β†’ API Tokens + * [More Help](https://docs.hetzner.com/cloud/api/getting-started/generating-api-token) +1. **Add both tokens as repository secrets:** + * GitHub β†’ Select repository β†’ Settings β†’ Secrets and variables β†’ Actions β†’ New repository secrets + * `PERSONAL_ACCESS_TOKEN`: Your GitHub Personal Access Token + * `HCLOUD_TOKEN`: Your Hetzner Cloud API Token + * [More Help](https://docs.github.com/actions/security-guides/encrypted-secrets) +1. **Create or adapt your workflow** following the example below. + +## Example + +Example GitHub Actions Workflow: + +```yml +name: "Example" +on: + workflow_dispatch: + +jobs: + create-runner: + name: Create Hetzner Cloud runner + runs-on: ubuntu-24.04 + outputs: + label: ${{ steps.create-hcloud-runner.outputs.label }} + server_id: ${{ steps.create-hcloud-runner.outputs.server_id }} + steps: + - name: Create runner + id: create-hcloud-runner + uses: Cyclenerd/hcloud-github-runner@v1 + with: + mode: create + github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + hcloud_token: ${{ secrets.HCLOUD_TOKEN }} + server_type: cx22 + image: rocky-9 # Rocky Linux 9 + + do-the-job: + name: Do the job on the runner + needs: create-runner # required to start the main job when the runner is ready + runs-on: ${{ needs.create-runner.outputs.label }} # run the job on the newly created runner + steps: + - name: Hello from runner + run: | + dnf install podman -y + podman run "quay.io/podman/hello:latest" + + delete-runner: + name: Delete Hetzner Cloud runner + needs: + - create-runner # required to get output from the create-runner job + - do-the-job # required to wait when the main job is done + runs-on: ubuntu-24.04 + if: ${{ always() }} # required to stop the runner even if the error happened in the previous jobs + steps: + - name: Delete runner + uses: Cyclenerd/hcloud-github-runner@v1 + with: + mode: delete + github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + hcloud_token: ${{ secrets.HCLOUD_TOKEN }} + name: ${{ needs.create-runner.outputs.label }} + server_id: ${{ needs.create-runner.outputs.server_id }} +``` + +## Inputs + +> [!TIP] +> Use an SSH key ID (`ssh_key`) to disable root password generation and Hetzner Cloud email notifications. + +| Name | Required | Description | Default | +|---------------------|----------|-------------|---------| +| `enable_ipv4` | | Attach an IPv4 on the public NIC (true/false). If false, no IPv4 address will be attached. Warning: The GitHub API requires IPv4. Disabling it will result in connection failures. | `true` | +| `enable_ipv6` | | Attach an IPv6 on the public NIC (true/false). If false, no IPv6 address will be attached. | `true` | +| `github_token` | βœ“ (always) | Fine-grained GitHub Personal Access Token (PAT) with 'Read and write' access to 'Administration' assigned. | | +| `hcloud_token` | βœ“ (always) | Hetzner Cloud API token with 'Read & Write' permissions assigned. | | +| `image` | | Name or ID (integer) of the Image the Server is created from. | `ubuntu-24.04` (Ubuntu 24.04) | +| `location` | | Name of Location to create Server in. | `nbg1` (NΓΌrnberg 1) | +| `mode` | βœ“ (always) | Choose either `create` to create a new GitHub Actions Runner or `delete` to delete a previously created one. | | +| `name` | βœ“ (mode `delete`, optional for mode `create`) | The name for the server and label for the GitHub Actions Runner (must be unique within the project and conform to hostname rules: `^[a-zA-Z0-9_-]{1,64}`). | `gh-runner-[RANDOM-INT]` | +| `network` | | Network ID (integer) which should be attached to the Server private network interface at the creation time. | `null` | +| `pre_runner_script` | | Specifies bash commands to run before the GitHub Actions Runner starts. It's useful for installing dependencies with apt-get, dnf, zypper etc. | | +| `primary_ipv4` | | ID (integer) of the IPv4 Primary IP to use. If omitted and `enable_ipv4` is true, a new IPv4 Primary IP will automatically be created. | `null` | +| `primary_ipv6` | | ID (integer) of the IPv6 Primary IP to use. If omitted and `enable_ipv6` is true, a new IPv6 Primary IP will automatically be created. | `null` | +| `runner_dir` | | GitHub Actions Runner installation directory (created automatically; no trailing slash). | `/actions-runner` | +| `runner_version` | | GitHub Actions Runner version (omit "v"; e.g., "2.321.0"). "latest" will install the latest version. "skip" will skip the installation. A working installation is expected in the `runner_dir`. | `latest` | +| `runner_wait` | | Wait up to `runner_wait` retries (10 sec each) for runner registration. | `60` (10 min) | +| `server_id` | βœ“ (mode `stop`) | ID (integer) of Hetzner Cloud Server to delete. | | +| `server_type` | | Name of the Server type this Server should be created with. | `cx22` (Intel x86, 2 vCPU, 4GB RAM, 40GB SSD) | +| `server_wait` | | Wait up to `server_wait` retries (10 sec each) for the Hetzner Cloud Server to start. | `30` (5 min) | +| `ssh_key` | | SSH key ID (integer) which should be injected into the Server at creation time. | `null` | + +## Outputs + +| Name | Description | +|-------------|-------------| +| `label` | This label uniquely identifies a GitHub Actions runner, used both to specify which runner a job should execute on via the `runs-on` property and to delete the runner when it's no longer needed. | +| `server_id` | This is the Hetzner Cloud Server ID of the runner, used to delete the server when the runner is no longer required. | + +## Snippets + +The following `hcloud` CLI commands can help you find the required input values. + +**List Locations:** + +```bash +hcloud location list --output "columns=NAME,DESCRIPTION,NETWORK_ZONE,COUNTRY,CITY" --sort "name" +``` + +```text +NAME DESCRIPTION NETWORK ZONE COUNTRY CITY +ash Ashburn, VA us-east US Ashburn, VA +fsn1 Falkenstein DC Park 1 eu-central DE Falkenstein +hel1 Helsinki DC Park 1 eu-central FI Helsinki +hil Hillsboro, OR us-west US Hillsboro, OR +nbg1 Nuremberg DC Park 1 eu-central DE Nuremberg +sin Singapore ap-southeast SG Singapore +``` + +**List Server Types:** + +```bash +hcloud server-type list --output "columns=NAME,CORES,CPU_TYPE,ARCHITECTURE,MEMORY,DISK" +``` + +```text +NAME CORES CPU TYPE ARCHITECTURE MEMORY DISK +cpx11 2 shared x86 2.0 GB 40 GB +cpx21 3 shared x86 4.0 GB 80 GB +cpx31 4 shared x86 8.0 GB 160 GB +cpx41 8 shared x86 16.0 GB 240 GB +cpx51 16 shared x86 32.0 GB 360 GB +cax11 2 shared arm 4.0 GB 40 GB +cax21 4 shared arm 8.0 GB 80 GB +cax31 8 shared arm 16.0 GB 160 GB +cax41 16 shared arm 32.0 GB 320 GB +ccx13 2 dedicated x86 8.0 GB 80 GB +ccx23 4 dedicated x86 16.0 GB 160 GB +ccx33 8 dedicated x86 32.0 GB 240 GB +ccx43 16 dedicated x86 64.0 GB 360 GB +ccx53 32 dedicated x86 128.0 GB 600 GB +ccx63 48 dedicated x86 192.0 GB 960 GB +cx22 2 shared x86 4.0 GB 40 GB +cx32 4 shared x86 8.0 GB 80 GB +cx42 8 shared x86 16.0 GB 160 GB +cx52 16 shared x86 32.0 GB 320 GB +``` + +**List x86_64 Images:** + +```bash +hcloud image list --architecture "x86" --output "columns=NAME,DESCRIPTION" --type "system" --sort "name" +``` + +```text +NAME DESCRIPTION +alma-8 AlmaLinux 8 +alma-9 AlmaLinux 9 +centos-stream-9 CentOS Stream 9 +debian-11 Debian 11 +debian-12 Debian 12 +fedora-39 Fedora 39 +fedora-40 Fedora 40 +fedora-41 Fedora 41 +opensuse-15 openSUSE Leap 15 +rocky-8 Rocky Linux 8 +rocky-9 Rocky Linux 9 +ubuntu-20.04 Ubuntu 20.04 +ubuntu-22.04 Ubuntu 22.04 +ubuntu-24.04 Ubuntu 24.04 +``` + +**List ARM Images:** + +```bash +hcloud image list --architecture "arm" --output "columns=NAME,DESCRIPTION" --type "system" --sort "name" +``` + +**Create custom Image:** + +Before you create a custom image of your Hetzner Cloud Server, [clean](https://cloudinit.readthedocs.io/en/latest/reference/cli.html#clean) up the server by running on the server: + +```bash +cloud-init clean --logs --machine-id --seed --configs all +``` + +Important: Do not shut down the server. + +Create Image: + +```bash +hcloud server create-image --type "snapshot" --description "github-runner-image" "[SERVER-ID]" +``` + +Tip: Create custom images with HashiCorp [Packer](https://github.com/hetznercloud/packer-plugin-hcloud). + +**List custom Images:** + +```bash +hcloud image list --output "columns=ID,DESCRIPTION,ARCHITECTURE,DISK_SIZE" --type "snapshot" --sort "name" +``` + +**List Primary IPs:** + +```bash +hcloud primary-ip list --output "columns=ID,TYPE,NAME,IP,ASSIGNEE" +``` + +**List Networks:** + +```bash +hcloud network list --output "columns=ID,NAME,IP_RANGE,SERVERS" +``` + +**List SSH Keys:** + +```bash +hcloud ssh-key list --output "columns=ID,NAME" +``` + +## Security + +> We recommend that you only use self-hosted runners with private repositories. +> This is because forks of your public repository can potentially run dangerous code on your self-hosted runner machine by creating a pull request that executes the code in a workflow. + +For security considerations, see the [GitHub documentation](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners#self-hosted-runner-security). + +## Contributing + +Have a patch that will benefit this project? +Awesome! Follow these steps to have it accepted. + +1. Please read [how to contribute](CONTRIBUTING.md). +1. Fork this Git repository and make your changes. +1. Create a Pull Request. +1. Incorporate review feedback to your changes. +1. Accepted! + +## Credits + +This GitHub Action is based on the idea and implementation of [Volodymyr Machula](https://github.com/machulav) for [AWS EC2 runner](https://github.com/machulav/ec2-github-runner). + +Two additional implementations for GitHub Actions runners on Hetzner Cloud are also worth noting: + +* [GitHub Action for Hetzner Cloud Self-Hosted Runners](https://github.com/stonemaster/hetzner-github-runner) from [AndrΓ© Stein](https://github.com/stonemaster) +* [Ephemeral GitHub runners on Hetzner Cloud](https://github.com/Kwarf/hetzner-ephemeral-runner) from [Jimmy BergstrΓΆm](https://github.com/Kwarf) + +## License + +All files in this repository are under the [Apache License, Version 2.0](LICENSE) unless noted otherwise. diff --git a/action.sh b/action.sh new file mode 100644 index 0000000..c8ec8b9 --- /dev/null +++ b/action.sh @@ -0,0 +1,453 @@ +#!/usr/bin/env bash + +# Copyright 2024 Nils Knieling. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Create a on-demand self-hosted GitHub Actions Runner in Hetzner Cloud +# https://docs.hetzner.cloud/#servers-create-a-server + +# Function to exit the script with a failure message +function exit_with_failure() { + echo >&2 "FAILURE: $1" # Print error message to stderr + exit 1 +} + +# Define required commands +MY_COMMANDS=( + base64 + curl + cut + envsubst + jq +) +# Check if required commands are available +for MY_COMMAND in "${MY_COMMANDS[@]}"; do + if ! command -v "$MY_COMMAND" >/dev/null 2>&1; then + exit_with_failure "The command '$MY_COMMAND' was not found. Please install it." + fi +done + +# Check if files exist +MY_FILES=( + "cloud-init.template.yml" + "create-server.template.json" + "install.sh" +) +# Check if required commands are available +for MY_FILE in "${MY_FILES[@]}"; do + if [[ ! -f "$MY_FILE" ]]; then + exit_with_failure "The file '$MY_FILE' was not found!" + fi +done + +# +# INPUT +# + +# GitHub Actions inputs +# https://docs.github.com/en/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions#inputs +# When you specify an input, GitHub creates an environment variable for the input with the name INPUT_. + +# Set the Hetzner Cloud API token. +# Retrieves the value from the INPUT_HCLOUD_TOKEN environment variable. +MY_HETZNER_TOKEN=${INPUT_HCLOUD_TOKEN} +if [[ -z "$MY_HETZNER_TOKEN" ]]; then + exit_with_failure "Hetzner Cloud API token is not set." +fi + +# Set the GitHub Personal Access Token (PAT). +# Retrieves the value from the INPUT_GITHUB_TOKEN environment variable. +MY_GITHUB_TOKEN=${INPUT_GITHUB_TOKEN} +if [[ -z "$MY_GITHUB_TOKEN" ]]; then + exit_with_failure "GitHub Personal Access Token (PAT) token is required!" +fi + +# Set the GitHub repository name. +# This retrieves the value from the GITHUB_ACTION_REPOSITORY environment variable, +# which is automatically set in GitHub Actions workflows. +# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables +MY_GITHUB_REPOSITORY=${GITHUB_REPOSITORY} +if [[ -z "$MY_GITHUB_REPOSITORY" ]]; then + exit_with_failure "GitHub repository is required!" +fi +# Set the repository owner's account ID (used for Hetzner Cloud Server label). +MY_GITHUB_REPOSITORY_OWNER_ID=${GITHUB_REPOSITORY_OWNER_ID:-"0"} +# Set The ID of the repository (used for Hetzner Cloud Server label). +MY_GITHUB_REPOSITORY_ID=${GITHUB_REPOSITORY_ID:-"0"} + +# Specify here which mode you want to use (default: create): +# - create : Create a new runner +# - delete : Delete the previously created runner +# If INPUT_MODE is set, use its value; otherwise, use "create". +MY_MODE=${INPUT_MODE:-"create"} +if [[ "$MY_MODE" != "create" && "$MY_MODE" != "delete" ]]; then + exit_with_failure "Mode must be 'create' or 'delete'." +fi + +# Enable IPv4 (default: false) +# If INPUT_ENABLE_IPV4 is set, use its value; otherwise, use "false". +MY_ENABLE_IPV4=${INPUT_ENABLE_IPV4:-"true"} +if [[ "$MY_ENABLE_IPV4" != "true" && "$MY_ENABLE_IPV4" != "false" ]]; then + exit_with_failure "Enable IPv4 must be 'true' or 'false'." +fi + +# Enable IPv6 (default: true) +# If INPUT_ENABLE_IPV6 is set, use its value; otherwise, use "true". +MY_ENABLE_IPV6=${INPUT_ENABLE_IPV6:-"true"} +if [[ "$MY_ENABLE_IPV6" != "true" && "$MY_ENABLE_IPV6" != "false" ]]; then + exit_with_failure "Enable IPv6 must be 'true' or 'false'." +fi + +# Set the image to use for the instance (default: ubuntu-24.04) +# If INPUT_IMAGE is set, use its value; otherwise, use "ubuntu-24.04". +MY_IMAGE=${INPUT_IMAGE:-"ubuntu-24.04"} +# Check allowed characters +if [[ ! "$MY_IMAGE" =~ ^[a-zA-Z0-9\._-]{1,63}$ ]]; then + exit_with_failure "'$MY_IMAGE' is not a valid OS image name!" +fi + +# Set the location/region for the instance (default: nbg1) +# If INPUT_LOCATION is set, use its value; otherwise, use "nbg1". +MY_LOCATION=${INPUT_LOCATION:-"nbg1"} + +# Set the name of the instance (default: gh-runner-$RANDOM) +# If INPUT_NAME is set, use its value; otherwise, generate a random name using "gh-runner-$RANDOM". +MY_NAME=${INPUT_NAME:-"gh-runner-$RANDOM"} +# Check allowed characters +if [[ ! "$MY_NAME" =~ ^[a-zA-Z0-9_-]{1,64}$ ]]; then + exit_with_failure "'$MY_NAME' is not a valid hostname or label!" +fi +if [[ "$MY_NAME" == "hetzner" ]]; then + exit_with_failure "'hetzner' is not allowed as hostname!" +fi + +# Set the network for the instance (default: null) +# If INPUT_NETWORK is set, use its value; otherwise, use "null". +MY_NETWORK=${INPUT_NETWORK:-"null"} +# Check if MY_NETWORK is an integer +if [[ "$MY_NETWORK" != "null" && ! "$MY_NETWORK" =~ ^[0-9]+$ ]]; then + exit_with_failure "The network ID must be 'null' or an integer!" +fi + +# Set bash commands to run before the runner starts. +# If INPUT_PRE_RUNNER_SCRIPT is set, use its value; otherwise, use "". +MY_PRE_RUNNER_SCRIPT=${INPUT_PRE_RUNNER_SCRIPT:-""} + +# Set the primary IPv4 address for the instance (default: null) +# If INPUT_PRIMARY_IPV4 is set, use its value; otherwise, use "null". +MY_PRIMARY_IPV4=${INPUT_PRIMARY_IPV4:-"null"} +# Check if MY_PRIMARY_IPV4 is an integer +if [[ "$MY_PRIMARY_IPV4" != "null" && ! "$MY_PRIMARY_IPV4" =~ ^[0-9]+$ ]]; then + exit_with_failure "The primary IPv4 ID must be 'null' or an integer!" +fi + +# Set the primary IPv6 address for the instance (default: null) +# If INPUT_PRIMARY_IPV6 is set, use its value; otherwise, use "null". +MY_PRIMARY_IPV6=${INPUT_PRIMARY_IPV6:-"null"} +# Check if MY_PRIMARY_IPV6 is an integer +if [[ "$MY_PRIMARY_IPV6" != "null" && ! "$MY_PRIMARY_IPV6" =~ ^[0-9]+$ ]]; then + exit_with_failure "The primary IPv6 ID must be 'null' or an integer!" +fi + +# Set the server type/instance type (default: cx22) +# If INPUT_SERVER_TYPE is set, use its value; otherwise, use "cx22". +MY_SERVER_TYPE=${INPUT_SERVER_TYPE:-"cx22"} + +# Set maximal wait time (retries * 10 sec) for Hetzner Cloud Server (default: 30 [5 min]) +# If INPUT_SERVER_WAIT is set, use its value; otherwise, use "30". +MY_SERVER_WAIT=${INPUT_SERVER_WAIT:-"30"} +# Check if MY_RUNNER_WAIT is an integer +if [[ ! "$MY_SERVER_WAIT" =~ ^[0-9]+$ ]]; then + exit_with_failure "The maximum wait time (reties) for a running Hetzner Cloud Server must be an integer!" +fi + +# Set the SSH key to use for the instance (default: null) +# If INPUT_SSH_KEY is set, use its value; otherwise, use "null". +MY_SSH_KEY=${INPUT_SSH_KEY:-"null"} +# Check if MY_SSH_KEY is an integer +if [[ "$MY_SSH_KEY" != "null" && ! "$MY_SSH_KEY" =~ ^[0-9]+$ ]]; then + exit_with_failure "The SSH key ID must be 'null' or an integer!" +fi + +# Set default GitHub Actions Runner installation directory (default: /actions-runner) +# If INPUT_RUNNER_DIR is set, its value is used. Otherwise, the default value "/actions-runner" is used. +MY_RUNNER_DIR=${INPUT_RUNNER_DIR:-"/actions-runner"} +# Check allowed characters +if [[ ! "$MY_RUNNER_DIR" =~ ^/([^/]+/)*[^/]+$ ]]; then + exit_with_failure "'$MY_RUNNER_DIR' is not a valid absolute directory path without a trailing slash!" +fi + +# Set default GitHub Actions Runner version (default: latest) +# If INPUT_RUNNER_VERSION is set, its value is used. Otherwise, the default value "latest" is used. +# Releases: https://github.com/actions/runner/releases +MY_RUNNER_VERSION=${INPUT_RUNNER_VERSION:-"latest"} +# Check allowed values +if [[ "$MY_RUNNER_VERSION" != "latest" && "$MY_RUNNER_VERSION" != "skip" && ! "$MY_RUNNER_VERSION" =~ ^[0-9\.]{1,63}$ ]]; then + exit_with_failure "'$MY_RUNNER_VERSION' is not a valid GitHub Actions Runner version! Enter 'latest', 'skip' or the version without 'v'." +fi + +# Set maximal wait time (retries * 10 sec) for GitHub Actions Runner registration (default: 30 [5 min]) +# If MY_RUNNER_WAIT is set, use its value; otherwise, use "30". +MY_RUNNER_WAIT=${INPUT_RUNNER_WAIT:-"60"} +# Check if MY_RUNNER_WAIT is an integer +if [[ ! "$MY_RUNNER_WAIT" =~ ^[0-9]+$ ]]; then + exit_with_failure "The maximum wait time (reties) for GitHub Action Runner registration must be an integer!" +fi + +# Set Hetzner Cloud Server ID +MY_HETZNER_SERVER_ID=${INPUT_SERVER_ID} + + +# +# DELETE +# + +if [[ "$MY_MODE" == "delete" ]]; then + # Check if MY_HETZNER_SERVER_ID is an integer + if [[ ! "$MY_HETZNER_SERVER_ID" =~ ^[0-9]+$ ]]; then + exit_with_failure "Failed to get ID of the Hetzner Cloud Server!" + fi + + echo "Delete server..." + curl \ + -X DELETE \ + --fail-with-body \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${MY_HETZNER_TOKEN}" \ + "https://api.hetzner.cloud/v1/servers/$MY_HETZNER_SERVER_ID" \ + || exit_with_failure "Error deleting server!" + echo "Hetzner Cloud Server deleted successfully." + + echo "List self-hosted runners for repository..." + curl -L \ + --fail-with-body \ + -o "github-runners.json" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${MY_GITHUB_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${MY_GITHUB_REPOSITORY}/actions/runners" \ + || exit_with_failure "Failed to list GitHub Actions runners from repository!" + + MY_GITHUB_RUNNER_ID=$(jq -er ".runners[] | select(.name == \"$MY_NAME\") | .id" < "github-runners.json") + # Check if MY_GITHUB_RUNNER_ID is an integer + if [[ ! "$MY_GITHUB_RUNNER_ID" =~ ^[0-9]+$ ]]; then + exit_with_failure "Failed to get ID of the GitHub Actions Runner!" + fi + + echo "Delete GitHub Actions Runner from repository..." + curl -L \ + -X DELETE \ + --fail-with-body \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${MY_GITHUB_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${MY_GITHUB_REPOSITORY}/actions/runners/${MY_GITHUB_RUNNER_ID}" \ + || exit_with_failure "Failed to delete GitHub Actions Runner from repository! Please delete manually: https://github.com/${MY_GITHUB_REPOSITORY}/settings/actions/runners" + echo "GitHub Actions Runner deleted successfully." + echo + echo "The Hetzner Cloud Server and its associated GitHub Actions Runner have been deleted successfully." + # Add GitHub Action job summary + # https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#adding-a-job-summary + echo "The Hetzner Cloud Server and its associated GitHub Actions Runner have been deleted successfully πŸ—‘οΈ" >> "$GITHUB_STEP_SUMMARY" + exit 0 +fi + +# +# CREATE +# + +# Create GitHub Actions registration token for registering a self-hosted runner to a repository +# https://docs.github.com/en/rest/actions/self-hosted-runners#create-a-registration-token-for-a-repository +echo "Create GitHub Actions Runner registration token for GitHub repository..." +curl -L \ + -X "POST" \ + --fail-with-body \ + -o "registration-token.json" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${MY_GITHUB_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${MY_GITHUB_REPOSITORY}/actions/runners/registration-token" \ + || exit_with_failure "Failed to retrieve GitHub Actions Runner registration token!" + +# Read the GitHub Runner registration token from a file (assuming valid JSON) +MY_GITHUB_RUNNER_REGISTRATION_TOKEN=$(jq -er '.token' < "registration-token.json") + +# Encode the contents of the "install.sh" and runner script into base64 +# BSD +if [[ "$OSTYPE" == "darwin"* || "$OSTYPE" == "freebsd"* ]]; then + MY_INSTALL_SH_BASE64=$(base64 < "install.sh") + MY_PRE_RUNNER_SCRIPT_BASE64=$(echo "$MY_PRE_RUNNER_SCRIPT" | base64) +# GNU Core tools +else + MY_INSTALL_SH_BASE64=$(base64 --wrap=0 < "install.sh") + MY_PRE_RUNNER_SCRIPT_BASE64=$(echo "$MY_PRE_RUNNER_SCRIPT" | base64 --wrap=0) +fi +# Split repository into owner and repository name +MY_GITHUB_OWNER="${MY_GITHUB_REPOSITORY%/*}" # Extract the part before the last / +MY_GITHUB_REPO_NAME="${MY_GITHUB_REPOSITORY##*/}" # Extract the part after the last / + +# Export environment variables for use in the cloud-init template +export MY_GITHUB_OWNER +export MY_GITHUB_REPO_NAME +export MY_GITHUB_REPOSITORY +export MY_GITHUB_RUNNER_REGISTRATION_TOKEN +export MY_INSTALL_SH_BASE64 +export MY_NAME +export MY_PRE_RUNNER_SCRIPT_BASE64 +export MY_RUNNER_DIR +export MY_RUNNER_VERSION +# Substitute environment variables in the cloud-init template and create the final cloud-init configuration +if [[ ! -f "cloud-init.template.yml" ]]; then + exit_with_failure "cloud-init.template.yml not found!" +fi +envsubst < cloud-init.template.yml > cloud-init.yml + +# Generate the create-server.json file by populating the create-server.template.json template with variables. +# This uses jq to construct a JSON object based on the template and provided arguments. +# Optimize values for valid labels: https://docs.hetzner.cloud/#labels +echo "Generate server configuration..." +jq -n \ + --arg location "$MY_LOCATION" \ + --arg runner_version "$MY_RUNNER_VERSION" \ + --arg github_owner_id "$MY_GITHUB_REPOSITORY_OWNER_ID" \ + --arg github_repo_id "$MY_GITHUB_REPOSITORY_ID" \ + --arg image "$MY_IMAGE" \ + --arg server_type "$MY_SERVER_TYPE" \ + --arg name "$MY_NAME" \ + --argjson enable_ipv4 "$MY_ENABLE_IPV4" \ + --argjson enable_ipv6 "$MY_ENABLE_IPV6" \ + --rawfile cloud_init_yml "cloud-init.yml" \ + -f create-server.template.json > create-server.json \ + || exit_with_failure "Failed to generate create-server.json!" +# Add the primary IPv4 address if available (not "null") +if [[ "$MY_PRIMARY_IPV4" != "null" ]]; then + cp create-server.json create-server-ipv4.json && \ + jq ".public_net.ipv4 = $MY_PRIMARY_IPV4" < create-server-ipv4.json > create-server.json && \ + echo "Primary IPv4 ID added to create-server.json." +fi +# Add the primary IPv6 address if available (not "null") +if [[ "$MY_PRIMARY_IPV6" != "null" ]]; then + cp create-server.json create-server-ipv6.json && \ + jq ".public_net.ipv6 = $MY_PRIMARY_IPV6" < create-server-ipv6.json > create-server.json && \ + echo "Primary IPv6 ID added to create-server.json." +fi +# Add SSH key configuration to the create-server.json file if MY_SSH_KEY is not "null". +if [[ "$MY_SSH_KEY" != "null" ]]; then + cp create-server.json create-server-ssh.json && \ + jq ".ssh_keys += [$MY_SSH_KEY]" < create-server-ssh.json > create-server.json && \ + echo "SSH key added to create-server.json." +fi +# Add network configuration to the create-server.json file if MY_NETWORK is not "null". +if [[ "$MY_NETWORK" != "null" ]]; then + cp create-server.json create-server-network.json && \ + jq ".networks += [$MY_NETWORK]" < create-server-network.json > create-server.json && \ + echo "Network added to create-server.json." +fi + +# Send a POST request to the Hetzner Cloud API to create a server. +echo "Create server..." +if ! curl \ + -X POST \ + --fail-with-body \ + -o "servers.json" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${MY_HETZNER_TOKEN}" \ + -d @create-server.json \ + "https://api.hetzner.cloud/v1/servers"; then + cat "servers.json" + exit_with_failure "Failed to create Server in Hetzner Cloud!" +fi + +# Get the Hetzner Server ID from the JSON response (assuming valid JSON) +MY_HETZNER_SERVER_ID=$(jq -er '.server.id' < "servers.json") + +# Check if MY_HETZNER_SERVER_ID is an integer +if [[ ! "$MY_HETZNER_SERVER_ID" =~ ^[0-9]+$ ]]; then + exit_with_failure "Failed to get ID of the Hetzner Cloud Server!" +fi + +# Set GitHub Action output +# https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/ +#echo "::set-output name=label::$MY_NAME" +#echo "::set-output name=server_id::$MY_HETZNER_SERVER_ID" +echo "label=$MY_NAME" >> "$GITHUB_OUTPUT" +echo "server_id=$MY_HETZNER_SERVER_ID" >> "$GITHUB_OUTPUT" + +# Wait for server +MAX_RETRIES=$MY_SERVER_WAIT +WAIT_SEC=10 +RETRY_COUNT=0 +echo "Wait for server..." +while [[ $RETRY_COUNT -lt $MAX_RETRIES ]]; do + # Download and parse server status + curl -s \ + -o "servers.json" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${MY_HETZNER_TOKEN}" \ + "https://api.hetzner.cloud/v1/servers/$MY_HETZNER_SERVER_ID" \ + || exit_with_failure "Failed to get status of the Hetzner Cloud Server!" + + MY_HETZNER_SERVER_STATUS=$(jq -er '.server.status' < "servers.json") + + # Check if server is running + if [[ "$MY_HETZNER_SERVER_STATUS" == "running" ]]; then + echo "Server is running." + break + fi + + RETRY_COUNT=$((RETRY_COUNT + 1)) # Increment retry counter + + echo "Server is not running yet. Waiting $WAIT_SEC seconds... (Attempt $RETRY_COUNT/$MAX_RETRIES)" + sleep "$WAIT_SEC" +done +if [[ "$MY_HETZNER_SERVER_STATUS" != "running" ]]; then + exit_with_failure "Failed to start Hetzner Cloud Server! Please check manually." +fi + +# Wait for GitHub Actions Runner registration +MAX_RETRIES=$MY_RUNNER_WAIT +RETRY_COUNT=0 +echo "Wait for GitHub Actions Runner registration..." +while [[ $RETRY_COUNT -lt $MAX_RETRIES ]]; do + curl -L -s \ + -o "github-runners.json" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${MY_GITHUB_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${MY_GITHUB_REPOSITORY}/actions/runners" \ + || exit_with_failure "Failed to list GitHub Actions runners from repository!" + + MY_GITHUB_RUNNER_ID=$(jq -er ".runners[] | select(.name == \"$MY_NAME\") | .id" < "github-runners.json") + # Check if MY_GITHUB_RUNNER_ID is an integer + if [[ "$MY_GITHUB_RUNNER_ID" =~ ^[0-9]+$ ]]; then + echo "GitHub Actions Runner registered." + break + fi + + RETRY_COUNT=$((RETRY_COUNT + 1)) # Increment retry counter + + echo "GitHub Actions Runner is not yet registered. Wait $WAIT_SEC seconds... (Attempt $RETRY_COUNT/$MAX_RETRIES)" + sleep "$WAIT_SEC" +done +if [[ ! "$MY_GITHUB_RUNNER_ID" =~ ^[0-9]+$ ]]; then + exit_with_failure "GitHub Actions Runner is not registered. Please check installation manually." +fi + +echo +echo "The Hetzner Cloud Server and its associated GitHub Actions Runner are ready for use." +echo "Runner: https://github.com/${MY_GITHUB_REPOSITORY}/settings/actions/runners/${MY_GITHUB_RUNNER_ID}" +# Add GitHub Action job summary +# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#adding-a-job-summary +echo "The Hetzner Cloud Server and its associated [GitHub Actions Runner](https://github.com/${MY_GITHUB_REPOSITORY}/settings/actions/runners/${MY_GITHUB_RUNNER_ID}) are ready for use πŸš€" >> "$GITHUB_STEP_SUMMARY" +exit 0 diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..fe2fdeb --- /dev/null +++ b/action.yml @@ -0,0 +1,144 @@ +name: Self-Hosted GitHub Actions Runner on Hetzner Cloud +description: A GitHub Action to automatically create Hetzner Cloud servers and register them as self-hosted GitHub Actions runners. +author: Nils Knieling + +branding: + icon: 'server' + color: 'red' + +inputs: + mode: + description: >- + Choose either 'create' to create a new GitHub Actions Runner or 'delete' to delete a previously created one. + required: true + github_token: + description: >- + Fine-grained GitHub Personal Access Token (PAT) with 'Read and write' access to 'Administration' assigned. + required: true + hcloud_token: + description: >- + Hetzner Cloud API token with 'Read & Write' permissions assigned. + required: true + enable_ipv4: + description: >- + Attach an IPv4 on the public NIC (true/false) . If false, no IPv4 address will be attached. + Warning: The GitHub API requires IPv4. Disabling it will result in connection failures. + required: false + default: 'true' + enable_ipv6: + description: >- + Attach an IPv6 on the public NIC (true/false). If false, no IPv6 address will be attached. + required: false + default: 'true' + image: + description: >- + Name or ID (integer) of the Image the Server is created from. + required: false + default: 'ubuntu-24.04' + location: + description: >- + Name of Location to create Server in. + required: false + default: 'nbg1' + name: + description: >- + The name for the server and label for the GitHub Actions Runner (must be unique within the project and conform to hostname rules: '[a-zA-Z0-9_-]'). + required: false + network: + description: >- + Network ID (integer) which should be attached to the Server private network interface at the creation time. + required: false + default: 'null' + primary_ipv4: + description: >- + ID (integer) of the IPv4 Primary IP to use. + If omitted and 'enable_ipv4' is true, a new IPv4 Primary IP will automatically be created. + required: false + default: 'null' + primary_ipv6: + description: >- + ID (integer) of the IPv6 Primary IP to use. + If omitted and 'enable_ipv6' is true, a new IPv6 Primary IP will automatically be created. + required: false + default: 'null' + runner_dir: + description: >- + GitHub Actions Runner installation directory (created automatically; no trailing slash). + required: false + default: '/actions-runner' + runner_version: + description: >- + GitHub Actions Runner version (omit 'v'; e.g., '2.321.0'). + 'latest' will install the latest version. + 'skip' will skip the installation. A working installation is expected in the 'runner_dir'. + required: false + default: 'latest' + runner_wait: + description: >- + Wait up to 'runner_wait' retries (10 sec each) for runner registration (default: 10 minutes). + required: false + default: '60' + server_id: + description: >- + ID (integer) of Hetzner Cloud Server to delete. + required: false + server_type: + description: >- + Name of the Server type this Server should be created with. + required: false + default: 'cx22' + server_wait: + description: >- + Wait up to 'server_wait' retries (10 sec each) for the Hetzner Cloud Server to start (default: 30 = 5 min). + required: false + default: '30' + ssh_key: + description: >- + SSH key ID (integer) or name which should be injected into the Server at creation time. + required: false + default: 'null' + pre_runner_script: + description: >- + Specifies bash commands to run before the GitHub Actions Runner starts. + It's useful for installing dependencies with apt-get, dnf, zypper etc. + required: false + +outputs: + label: + description: >- + This label uniquely identifies a GitHub Actions runner, + used both to specify which runner a job should execute on via the `runs-on` property and to delete the runner when it's no longer needed. + value: ${{ steps.hcloud-github-runner.outputs.label }} + server_id: + description: >- + This is the Hetzner Cloud server ID of the runner, used to delete the server when the runner is no longer required. + value: ${{ steps.hcloud-github-runner.outputs.server_id }} + +runs: + using: "composite" + steps: + - name: GitHub Actions Runner in Hetzner Cloud + id: hcloud-github-runner + shell: bash + working-directory: ${{ github.action_path }} + run: bash action.sh + env: + INPUT_ENABLE_IPV4: ${{ inputs.enable_ipv4 }} + INPUT_ENABLE_IPV6: ${{ inputs.enable_ipv6 }} + INPUT_GITHUB_TOKEN: ${{ inputs.github_token }} + INPUT_HCLOUD_TOKEN: ${{ inputs.hcloud_token }} + INPUT_IMAGE: ${{ inputs.image }} + INPUT_LOCATION: ${{ inputs.location }} + INPUT_MODE: ${{ inputs.mode }} + INPUT_NAME: ${{ inputs.name }} + INPUT_NETWORK: ${{ inputs.network }} + INPUT_PRE_RUNNER_SCRIPT: ${{ inputs.pre_runner_script }} + INPUT_PRIMARY_IPV4: ${{ inputs.primary_ipv4 }} + INPUT_PRIMARY_IPV6: ${{ inputs.primary_ipv6 }} + INPUT_RUNNER_DIR: ${{ inputs.runner_dir }} + INPUT_RUNNER_VERSION: ${{ inputs.runner_version }} + INPUT_RUNNER_WAIT: ${{ inputs.runner_wait }} + INPUT_SERVER_ID: ${{ inputs.server_id }} + INPUT_SERVER_TYPE: ${{ inputs.server_type }} + INPUT_SERVER_WAIT: ${{ inputs.server_wait }} + INPUT_SSH_KEY: ${{ inputs.ssh_key }} diff --git a/cloud-init.template.yml b/cloud-init.template.yml new file mode 100644 index 0000000..13ec383 --- /dev/null +++ b/cloud-init.template.yml @@ -0,0 +1,22 @@ +#cloud-config +packages: + - curl + - git + - gzip + - jq + - tar +package_update: true +package_upgrade: false +runcmd: + - export RUNNER_ALLOW_RUNASROOT=1 + - bash $MY_RUNNER_DIR/pre_runner_script.sh + - bash $MY_RUNNER_DIR/install.sh -v "$MY_RUNNER_VERSION" -d "$MY_RUNNER_DIR" + - $MY_RUNNER_DIR/config.sh --url "https://github.com/${MY_GITHUB_REPOSITORY}" --token "${MY_GITHUB_RUNNER_REGISTRATION_TOKEN}" --name "${MY_NAME}" --labels "${MY_NAME},hetzner" --no-default-labels --disableupdate + - $MY_RUNNER_DIR/run.sh +write_files: + - path: $MY_RUNNER_DIR/pre_runner_script.sh + encoding: b64 + content: $MY_PRE_RUNNER_SCRIPT_BASE64 + - path: $MY_RUNNER_DIR/install.sh + encoding: b64 + content: $MY_INSTALL_SH_BASE64 diff --git a/create-server.template.json b/create-server.template.json new file mode 100644 index 0000000..40646fb --- /dev/null +++ b/create-server.template.json @@ -0,0 +1,21 @@ +{ + "location": $location, + "labels": { + "type": "github-runner", + "os-image": $image, + "gh-ver": $runner_version, + "gh-owner-id": $github_owner_id, + "gh-repo-id": $github_repo_id + }, + "image": $image, + "server_type": $server_type, + "name": $name, + "networks": [], + "public_net": { + "enable_ipv4": $enable_ipv4, + "enable_ipv6": $enable_ipv6, + }, + "start_after_create": true, + "ssh_keys": [], + "user_data": $cloud_init_yml, +} diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..bb6ff39 --- /dev/null +++ b/install.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash + +# Copyright 2024 Nils Knieling. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Install GitHub Actions Runner for Linux with x64 or ARM64 CPU architecture +# https://github.com/actions/runner +# https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners#linux + +# Get the script's name +MY_SCRIPT_NAME=$(basename "$0") + +# Set default GitHub Actions Runner version (latest) +MY_RUNNER_VERSION="latest" + +# Set default GitHub Actions Runner installation directory +MY_RUNNER_DIR="/actions-runner" + +# Function to exit the script with a failure message +function exit_with_failure() { + echo >&2 "FAILURE: $1" # Print error message to stderr + exit 1 +} + +# Function to display usage information +function usage { + MY_RETURN_CODE="$1" + echo -e "Usage: $MY_SCRIPT_NAME [-v ] [-d ] [-h]: + [-v ] Version (without 'v') of the GitHub Actions Runner. (default: $MY_RUNNER_VERSION) + [-d ] Directory for the GitHub Actions Runner installation. (default: $MY_RUNNER_DIR) + [-h] Displays this message." + exit "$MY_RETURN_CODE" +} + +# If version is "skip", skip GitHub Actions Runner installation. +if [[ "$MY_RUNNER_VERSION" = "skip" ]]; then + exit 0 +fi + +# Define required commands +MY_COMMANDS=( + curl + gzip + jq + sed + tar +) +# Check if required commands are available +for MY_COMMAND in "${MY_COMMANDS[@]}"; do + if ! command -v "$MY_COMMAND" >/dev/null 2>&1; then + exit_with_failure "The command '$MY_COMMAND' was not found. Please install it." + fi +done + +# Detect CPU architecture +case $(uname -m) in +aarch64|arm64) + MY_ARCH="arm64" + ;; +amd64|x86_64) + MY_ARCH="x64" + ;; +*) + exit_with_failure "Cannot determine CPU architecture!" +esac + +# Process command line arguments +while getopts ":v:d:h" opt; do + case $opt in + v) + MY_RUNNER_VERSION="$OPTARG" + ;; + d) + MY_RUNNER_DIR="$OPTARG" + ;; + h) + usage 0 + ;; + *) + echo "Invalid option: -$OPTARG" + usage 1 + ;; + esac +done + +# If version is "latest", fetch the latest version from GitHub API +if [[ "$MY_RUNNER_VERSION" = "latest" ]]; then + MY_RUNNER_LATEST_VERSION=$(curl -sL "https://api.github.com/repos/actions/runner/releases/latest" | jq -r '.tag_name' | sed -e 's/^v//') + MY_RUNNER_VERSION="$MY_RUNNER_LATEST_VERSION" + if [[ -z "$MY_RUNNER_LATEST_VERSION" || "null" == "$MY_RUNNER_LATEST_VERSION" ]]; then + exit_with_failure "Could not retrieve the latest GitHub Actions Runner version!" + fi + echo "GitHub Actions Runner version 'v${MY_RUNNER_LATEST_VERSION}' is detected as the latest version." +else + echo "GitHub Actions Runner version 'v$MY_INPUT_RUNNER_VERSION' is specified as version." + MY_RUNNER_VERSION="$MY_INPUT_RUNNER_VERSION" +fi + +# Create directory (if it doesn't exist) and change to the installation directory +mkdir -p "$MY_RUNNER_DIR" && \ +cd "$MY_RUNNER_DIR" && \ +# Download the GitHub Actions Runner archive +curl -O -L "https://github.com/actions/runner/releases/download/v${MY_RUNNER_VERSION}/actions-runner-linux-${MY_ARCH}-${MY_RUNNER_VERSION}.tar.gz" && \ +tar xzf "actions-runner-linux-${MY_ARCH}-${MY_RUNNER_VERSION}.tar.gz" + +# Patch for Ubuntu 24.04 (https://github.com/actions/runner/issues/3150) +# This patch might be necessary for successful installation on Ubuntu 24.04 +sed -i 's/libicu72/libicu72 libicu74/' ./bin/installdependencies.sh + +# Run the installation script +./bin/installdependencies.sh && \ +echo "GitHub Actions Runner installed successfully."