├── requirements.txt ├── custom_logger.py ├── encrypt.py ├── LICENSE ├── action.yaml ├── requester.py ├── .gitignore ├── api.py ├── README.md └── deploy.py /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.26.0 2 | cryptography==42.0.5 3 | typer==0.9.0 -------------------------------------------------------------------------------- /custom_logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | class StructuredLogger(logging.Logger): 5 | def _log( 6 | self, 7 | level, 8 | msg, 9 | args, 10 | exc_info=None, 11 | extra=None, 12 | stack_info=False, 13 | stacklevel=1, 14 | ): 15 | if isinstance(msg, dict): 16 | structured_msg = " | ".join(f"{key}={value}" for key, value in msg.items()) 17 | super()._log( 18 | level, structured_msg, args, exc_info, extra, stack_info, stacklevel 19 | ) 20 | else: 21 | super()._log(level, msg, args, exc_info, extra, stack_info, stacklevel) 22 | -------------------------------------------------------------------------------- /encrypt.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | 4 | from cryptography.fernet import Fernet, InvalidToken 5 | 6 | 7 | def compute_md5(file_path): 8 | hash_md5 = hashlib.md5() 9 | with open(file_path, "rb") as f: 10 | for chunk in iter(lambda: f.read(4096), b""): 11 | hash_md5.update(chunk) 12 | return hash_md5.hexdigest() 13 | 14 | 15 | def encode_key(key): 16 | return base64.urlsafe_b64encode(key.encode()) 17 | 18 | 19 | def encrypt_data(data, key): 20 | encodded_key = encode_key(key) 21 | f = Fernet(encodded_key) 22 | return f.encrypt(data.encode()) 23 | 24 | 25 | def decrypt_data(encrypted_data, key): 26 | encodded_key = encode_key(key) 27 | f = Fernet(encodded_key) 28 | try: 29 | return f.decrypt(encrypted_data).decode() 30 | except InvalidToken: 31 | raise ValueError("Invalid encryption key. Please provide the correct key or do a fresh deployment.") 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Matheus Pinheiro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /action.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy to Nekoweb 2 | description: Deploy to Nekoweb using the Nekoweb API 3 | author: fairfruit 4 | inputs: 5 | API_KEY: 6 | description: API key for Nekoweb 7 | required: true 8 | BUILD_DIR: 9 | description: Local build directory to deploy 10 | required: true 11 | default: /build 12 | DEPLOY_DIR: 13 | description: Remote directory to deploy to 14 | required: true 15 | default: / 16 | NEKOWEB_PAGENAME: 17 | description: Your NekoWeb page name (your username unless you use a custom domain). 18 | required: true 19 | CLEANUP: 20 | description: Also delete remote files that don't exist locally 21 | default: "False" 22 | DELAY: 23 | description: Delay between Nekoweb API requests (in seconds) 24 | required: false 25 | RETRY_ATTEMTPS: 26 | description: Number of times to retry the Nekoweb API request 27 | required: false 28 | RETRY_DELAY: 29 | description: Delay between Nekoweb API request retries (in seconds) when rate limited 30 | required: false 31 | RETRY_EXP_BACKOFF: 32 | description: Exponential backoff for Nekoweb API request retries 33 | required: false 34 | ENCRYPTION_KEY: 35 | description: A secret key used to encrypt the file states. Must be a 32-byte URL-safe base64-encoded string 36 | required: false 37 | DEBUG: 38 | description: Enable debug mode 39 | required: false 40 | branding: 41 | icon: upload-cloud 42 | color: blue 43 | runs: 44 | using: "composite" 45 | steps: 46 | - name: Setup Python 47 | uses: actions/setup-python@v5 48 | with: 49 | python-version: "3.x" 50 | 51 | - name: Install dependencies 52 | run: python -m pip install -r ${{ github.action_path }}/requirements.txt 53 | shell: bash 54 | 55 | - name: Cleanup and deploy 56 | run: | 57 | CMD="python ${{ github.action_path }}/deploy.py ${{ inputs.API_KEY }} ${{ inputs.BUILD_DIR }} ${{ inputs.DEPLOY_DIR }} ${{ inputs.CLEANUP }} ${{ inputs.NEKOWEB_PAGENAME }}" 58 | if [[ -n "${{ inputs.DELAY }}" ]]; then 59 | CMD="$CMD --delay ${{ inputs.DELAY }}" 60 | fi 61 | if [[ -n "${{ inputs.ENCRYPTION_KEY }}" ]]; then 62 | CMD="$CMD --encryption-key ${{ inputs.ENCRYPTION_KEY }}" 63 | fi 64 | if [[ -n "${{ inputs.RETRY_ATTEMPTS }}" ]]; then 65 | CMD="$CMD --retry-attempts ${{ inputs.RETRY_ATTEMPTS }}" 66 | fi 67 | if [[ -n "${{ inputs.RETRY_DELAY }}" ]]; then 68 | CMD="$CMD --retry-delay ${{ inputs.RETRY_DELAY }}" 69 | fi 70 | 71 | RETRY_EXP_BACKOFF_INPUT="${{ inputs.RETRY_EXP_BACKOFF }}" 72 | if [[ "${RETRY_EXP_BACKOFF_INPUT,,}" == "true" ]]; then 73 | CMD="$CMD --retry-exp-backoff" 74 | fi 75 | 76 | DEBUG_INPUT="${{ inputs.DEBUG }}" 77 | if [[ "${DEBUG_INPUT,,}" == "true" ]]; then 78 | CMD="$CMD --debug" 79 | fi 80 | 81 | echo "Running command: $CMD" 82 | eval $CMD 83 | shell: bash 84 | -------------------------------------------------------------------------------- /requester.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | import requests 5 | from requests.exceptions import HTTPError 6 | 7 | logger = logging.getLogger("neko-deploy") 8 | 9 | 10 | class Requester: 11 | _shared_state = {} 12 | 13 | def __init__(self, max_retries=5, backoff_factor=1, exponential_backoff=False): 14 | self.__dict__ = self._shared_state 15 | if not hasattr(self, "max_retries"): 16 | self.max_retries = max_retries 17 | if not hasattr(self, "backoff_factor"): 18 | self.backoff_factor = backoff_factor 19 | if not hasattr(self, "exponential_backoff"): 20 | self.exponential_backoff = exponential_backoff 21 | 22 | def request(self, method, url, **kwargs): 23 | """Make a request to the given URL with the given method and keyword arguments. 24 | 25 | Args: 26 | method (str): The HTTP method to use for the request. 27 | url (str): The URL to make the request to. 28 | ingored_errors (dict): A dictionary of status codes and error messages to ignore. 29 | {400: {"message": "File/folder already exists"}} 30 | {404: {"partial_message": "not exist"}} 31 | {404: {"ignore_all": True}} 32 | **kwargs: Additional keyword arguments to pass to the requests.request method. 33 | 34 | Returns: 35 | requests.Response: The response object for the request. 36 | 37 | Raises: 38 | HTTPError: If the request fails after the maximum number of retries. 39 | """ 40 | retries = self.max_retries 41 | ignored_errors = kwargs.pop("ignored_errors", {}) 42 | retry_time = self.backoff_factor 43 | 44 | while retries > 0: 45 | logger.debug({"message": "Making request", "method": method, "url": url, "retries": retries, **kwargs}) 46 | response = requests.request(method, url, **kwargs) 47 | 48 | if response.status_code == 200: 49 | return response 50 | elif response.status_code == 429: 51 | time.sleep(self.backoff_factor) 52 | retries -= 1 53 | if self.exponential_backoff: 54 | retry_time *= 2 55 | else: 56 | # check if the error is in the ignored_errors dictionary 57 | if response.status_code in ignored_errors: 58 | error = ignored_errors[response.status_code] 59 | # check if the error message is the same as the response text 60 | if ( 61 | error.get("ignore_all") 62 | or error.get("message") == response.text 63 | or error.get("partial_message") in response.text 64 | ): 65 | return response 66 | 67 | # raise an exception if the error is not in the ignored_errors dictionary 68 | try: 69 | response.raise_for_status() 70 | except HTTPError as http_err: 71 | raise HTTPError( 72 | f"HTTP error occurred: {http_err}, Status Code: {response.status_code}, Response Text: {response.text}" # noqa 73 | ) 74 | 75 | raise HTTPError(f"Max retries exceeded for URL: {url} ({retries} retries)") 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | .vscode -------------------------------------------------------------------------------- /api.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | 5 | from requests.exceptions import HTTPError 6 | 7 | from encrypt import decrypt_data, encrypt_data 8 | from requester import Requester 9 | 10 | logger = logging.getLogger("neko-deploy") 11 | 12 | # constants 13 | NEKOWEB_API_SPECIAL_FILES = ["/elements.css", "/not_found.html", "/cursor.png"] 14 | 15 | 16 | class NekoWebAPI: 17 | def __init__(self, api_key, base_url, page_name): 18 | self.api_key = api_key 19 | self.base_url = f"https://{base_url}/api" 20 | self.page_url = f"https://{page_name}.{base_url}" 21 | self.requester = Requester() 22 | 23 | def create_directory(self, pathname): 24 | data = {"isFolder": "true", "pathname": pathname} 25 | response = self.requester.request( 26 | "POST", 27 | f"{self.base_url}/files/create", 28 | headers={"Authorization": self.api_key}, 29 | data=data, 30 | ignored_errors={400: {"message": "File/folder already exists"}}, 31 | ) 32 | return response.ok 33 | 34 | def upload_file(self, filepath, server_path): 35 | with open(filepath, "rb") as f: 36 | files = {"files": (os.path.basename(filepath), f, "application/octet-stream")} 37 | data = {"pathname": os.path.dirname(server_path)} 38 | response = self.requester.request( 39 | "POST", 40 | f"{self.base_url}/files/upload", 41 | headers={"Authorization": self.api_key}, 42 | data=data, 43 | files=files, 44 | ) 45 | return response.ok 46 | 47 | def edit_file(self, filepath, server_path): 48 | with open(filepath, "r") as f: 49 | files = {"pathname": (None, server_path), "content": (None, f.read())} 50 | response = self.requester.request( 51 | "POST", f"{self.base_url}/files/edit", headers={"Authorization": self.api_key}, files=files 52 | ) 53 | return response.ok 54 | 55 | def list_files(self, pathname): 56 | try: 57 | response = self.requester.request( 58 | "GET", 59 | f"{self.base_url}/files/readfolder?pathname={pathname}", 60 | headers={"Authorization": self.api_key}, 61 | ) 62 | except HTTPError: 63 | return [] 64 | 65 | return response.json() if response.ok else [] 66 | 67 | def delete_file_or_directory(self, pathname, ignore_not_found=False): 68 | data = {"pathname": pathname} 69 | 70 | ignored = {} 71 | if ignore_not_found: 72 | ignored = {400: {"message": "File/folder does not exist"}} 73 | 74 | response = self.requester.request( 75 | "POST", 76 | f"{self.base_url}/files/delete", 77 | headers={"Authorization": self.api_key}, 78 | data=data, 79 | ignored_errors=ignored, 80 | ) 81 | return response.ok 82 | 83 | def fetch_file_states(self, deploy_dir, encryption_key=None): 84 | # validate page url 85 | try: 86 | response = self.requester.request( 87 | "GET", 88 | self.page_url, 89 | ) 90 | except HTTPError: 91 | logger.warning( 92 | { 93 | "message": f"Could not validate URL: `{self.page_url}`, a full deployment will be performed. " 94 | "Check your `NEKOWEB_PAGENAME` parameter.", 95 | "url": self.page_url, 96 | } 97 | ) 98 | return {} 99 | 100 | # fetch the file states 101 | file_states_url = f"{self.page_url}/{deploy_dir}/_file_states" 102 | response = self.requester.request( 103 | "GET", 104 | file_states_url, 105 | headers={"Authorization": self.api_key}, 106 | ignored_errors={404: {"ignore_all": True}}, 107 | ) 108 | 109 | if not response.ok: 110 | return {} 111 | 112 | if encryption_key: 113 | return json.loads(decrypt_data(response.content, encryption_key)) 114 | 115 | try: 116 | return response.json() 117 | except json.JSONDecodeError: 118 | raise ValueError( 119 | "Invalid encryption key provided. Please provide the correct key or do a fresh deployment." 120 | ) 121 | 122 | def update_file_states(self, file_states, file_states_path, deploy_dir, encryption_key=None): 123 | file_states_json = json.dumps(file_states) 124 | 125 | if encryption_key: 126 | encrypted_file_states = encrypt_data(file_states_json, encryption_key) 127 | with open(file_states_path, "wb") as f: 128 | f.write(encrypted_file_states) 129 | else: 130 | with open(file_states_path, "w") as f: 131 | f.write(file_states_json) 132 | 133 | return self.upload_file(file_states_path, f"{deploy_dir}/_file_states") 134 | 135 | def get_special_files(self): 136 | """Return a list of special Nekoweb files that cannot be deleted.""" 137 | return NEKOWEB_API_SPECIAL_FILES 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nekoweb Deploy Action 2 | 3 | Deploy to nekoweb using a Github action. 4 | 5 | # About this repository 6 | 7 | This action is not officially supported by Nekoweb. It is a community contribution. This version is in a very early stage and may not work as expected. 8 | 9 | All logic is contained in the `action.yml` and `deploy.py` files. The `action.yml` file is used to define the inputs and outputs of the action. The `deploy.py` file is used to define the logic of the action. 10 | 11 | ## Execution flow 12 | 13 | 1. Once triggered, the python script will check if the required parameters are present. 14 | 1. If the `CLEANUP` parameter is `True`, the deploy directory will be cleaned up, that is, **all files in the remote directory (your website) will be deleted**. It does this by retrieving the list of files in the deploy directory using the `files/readfolder` endpoint and deleting them using the `files/delete` endpoint. 15 | 1. The script will iterate over the files in the build directory recursively and send them to the Nekoweb API using the `files/upload` endpoint: 16 | 1. For directories, it will create the directory using the `files/create` endpoint. 17 | 1. For files, it will check for a `file_states` file in the deploy directory and compare the file's hash with the hash in the `file_states` file. If the hashes are the same, the file will not be uploaded. If the hashes are different, the file will be uploaded. If the `file_states` file does not exist, the file will be uploaded. The `NEKOWEB_PAGENAME` parameter is used to fetch the `file_states` file from the deploy directory. 18 | 1. The `file_states` file can be encrypted by passing an `ENCRYPTION_KEY` parameter. If the `ENCRYPTION_KEY` parameter is set, the `file_states` file will be encrypted using the `cryptography` library. The `file_states` file is used to avoid uploading files that have not changed, which can save time and API requests. 19 | 20 | ## Limitations 21 | 22 | - The action does not support the `files/upload` endpoint for files larger than 100MB. This is a limitation of the Nekoweb API. If you need to upload files larger than 100MB, you will need to use the Nekoweb web interface. The action will not fail, but it will not upload the files larger than 100MB. 23 | - There's no support for the `files/move` endpoint. This means that the action will not move files in the deploy directory. If you need to move files, you will need to use the Nekoweb web interface, or set the `CLEANUP` parameter to `True` which will delete all files in the deploy directory and then upload the build files. 24 | - A simple retry mechanism is implemented for API calls. If the API returns a 429 status code, the action will wait for 0.3 seconds and then retry the request. It'll retry the request 5 times before failing. This is a very simple mechanism and may not be enough to avoid rate limits. 25 | - If the deploy fails, the action will not clean up the deploy directory. This means that if the deploy fails, the deploy directory will be in an inconsistent state. You will need to clean it up manually or set the `CLEANUP` parameter to `True` to clean it up automatically, but be aware that this will delete all files in the deploy directory and will not benefit from the file states logic to avoid uploading files that have not changed. 26 | 27 | # Usage 28 | 29 | - Create a `.github/workflows/deploy.yml` file in your repository. 30 | - Add the following code to the `deploy.yml` file. 31 | - Parameters: 32 | - `API_KEY`: Your Nekoweb API key. It must be stored in the [Github repository secrets](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions). Example: `${{ secrets.API_KEY }}`. 33 | - `BUILD_DIR`: The directory where the build files are located. **Modify the "Prepare build" step to copy the build files to this directory.** Example: `./build` 34 | - `DEPLOY_DIR`: The directory where the build files will be deployed. Example: if your build files are located in `./build` and you want to deploy them to the root directory, use `/`. If you want to deploy them to a subdirectory, use the subdirectory path. Example: `/subdir` 35 | - `NEKOWEB_PAGENAME`: Your NekoWeb page name (your username unless you use a custom domain). Example: `fairfruit` 36 | - `CLEANUP`: If `True`, the deploy directory will be cleaned up before deploying the build files. **⚠ Use with caution, especially on the root directory. All files in the remote directory (your website) will be deleted.** This argument is optional and defaults to `False`. 37 | - `DELAY`: The delay between requests to the Nekoweb API. This is useful to avoid rate limits. Example: `0.5` (half a second). This argument is optional and defaults to `0.5`. 38 | - `RETRY_ATTEMPTS`: The number of retry attempts for API calls. If the API returns a 429 status code, the action will wait for the delay and retry the request. It'll retry the request this number of times before failing. This argument is optional and defaults to `5`. 39 | - `RETRY_DELAY`: The delay between retry attempts. This argument is optional and defaults to `1`. 40 | - `RETRY_EXP_BACKOFF`: If `True`, the delay between retry attempts will be exponential backoff. This argument is optional and defaults to `False`. 41 | - `ENCRYPTION_KEY`: A secret key used to encrypt the file states. Must be a 32-byte URL-safe base64-encoded string. You should also store this key in the [Github repository secrets](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions). Example: `${{ secrets.ENCRYPTION_KEY }}`. This argument is optional and no encryption will be used. **That means the file states will be stored in plain text in the deploy directory containing a list of all files and their hashes from your build directory. Use with caution.** 42 | 43 | ```yaml 44 | name: Deploy to Nekoweb 45 | 46 | on: 47 | push: 48 | branches: 49 | - main 50 | 51 | jobs: 52 | deploy: 53 | runs-on: ubuntu-latest 54 | 55 | steps: 56 | - name: Checkout repository 57 | uses: actions/checkout@v4 58 | 59 | - name: Prepare build 60 | run: | 61 | mkdir -p ./build 62 | cp -r ./public/* ./build 63 | 64 | - name: Deploy to Nekoweb 65 | uses: mp-pinheiro/nekoweb-deploy@main 66 | with: 67 | API_KEY: ${{ secrets.NEKOWEB_API_KEY }} 68 | BUILD_DIR: './build' 69 | DEPLOY_DIR: '/' 70 | CLEANUP: 'False' 71 | DELAY: '0.5' 72 | NEKOWEB_PAGENAME: 'fairfruit' 73 | ENCRYPTION_KEY: ${{ secrets.NEKOWEB_ENCRYPTION_KEY }} 74 | ``` 75 | 76 | Here's a working example in a Nekoweb website repository: https://github.com/mp-pinheiro/nekoweb-api-docs/blob/main/.github/workflows/main.yml 77 | 78 | # Using it locally 79 | 80 | You can use the action locally using the `deploy.py` script. You will need to install the dependencies using `pip install -r requirements.txt`. Then you can run the script using the following command: 81 | 82 | ```bash 83 | python deploy.py \ 84 | [--delay ] \ 85 | [--retry-attempts ] \ 86 | [--retry-delay ] \ 87 | [--retry-exp-backoff] \ 88 | [--encryption-key ] \ 89 | [--debug] \ 90 | \ 91 | \ 92 | \ 93 | \ 94 | 95 | ``` 96 | 97 | # Contributing 98 | 99 | This action is in a very early stage and may not work as expected. If you find any issues, please open an issue or a pull request. All contributions are welcome. 100 | 101 | Here are some ideas for contributions: 102 | 103 | - There's still room for improvements in the code. The code is not very clean and could be better organized. 104 | - Add a more robust and configurable retry mechanism for API calls. 105 | - Add support for deleting/renaming/moving files (besides the `CLEANUP` parameter) 106 | - Add support for files larger than 100MB using the `bigfiles` endpoints 107 | - Using `typer` for the CLI might conflict with the `handle_errors` decorator. 108 | -------------------------------------------------------------------------------- /deploy.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import logging 3 | import os 4 | import time 5 | 6 | import typer 7 | 8 | from api import NekoWebAPI 9 | from custom_logger import StructuredLogger 10 | from encrypt import compute_md5 11 | from requester import Requester 12 | 13 | logging.setLoggerClass(StructuredLogger) 14 | 15 | logging.basicConfig( 16 | level=logging.INFO, 17 | format="%(asctime)s [%(levelname)s] %(message)s", 18 | datefmt="%Y-%m-%dT%H:%M:%S", 19 | ) 20 | logger = logging.getLogger("neko-deploy") 21 | 22 | 23 | def handle_errors(func): 24 | @functools.wraps(func) 25 | def wrapper(*args, **kwargs): 26 | try: 27 | return func(*args, **kwargs) 28 | except Exception as e: 29 | details = None 30 | if type(e).__name__ == "HTTPError": 31 | details = None 32 | if e.response: 33 | details = e.response.text 34 | 35 | logger.error( 36 | { 37 | "message": "One or more errors occurred during deployment", 38 | "error": str(e), 39 | "details": details, 40 | "advice": ( 41 | "Please check NekoWeb as the state of the website might be corrupted. " 42 | "Consider downloading the build artifact and manually uploading the zip file as a workaround. " 43 | "You can also open an issue on the `mp-pinheiro/nekoweb-deploy` Github repository if needed." 44 | ), 45 | } 46 | ) 47 | 48 | if DEBUG: 49 | raise e 50 | else: 51 | exit(1) 52 | 53 | return wrapper 54 | 55 | 56 | app = typer.Typer() 57 | 58 | 59 | def cleanup_remote_directory(api, deploy_dir): 60 | logger.info({"message": "Cleaning up the server directory", "directory": deploy_dir}) 61 | if deploy_dir == "/": 62 | items = api.list_files(deploy_dir) 63 | for item in items: 64 | full_path = os.path.join(deploy_dir, item["name"]) 65 | 66 | # skip special files as they cannot be deleted 67 | if full_path in api.get_special_files(): 68 | continue 69 | 70 | logger.info({"message": "Deleting file", "file": full_path}) 71 | api.delete_file_or_directory(full_path) 72 | else: 73 | api.delete_file_or_directory(deploy_dir, ignore_not_found=True) 74 | 75 | logger.info({"message": "Server directory cleaned up", "directory": deploy_dir}) 76 | 77 | 78 | def deploy(api, build_dir, deploy_dir, encryption_key, delay=0.0): 79 | stats = { 80 | "message": "Deployed build to NekoWeb", 81 | "build_dir": build_dir, 82 | "deploy_dir": deploy_dir, 83 | "encryption_key": bool(encryption_key), 84 | "delay": delay, 85 | "files_uploaded": 0, 86 | "files_skipped": 0, 87 | "directories_created": 0, 88 | "directories_skipped": 0, 89 | } 90 | 91 | file_states = api.fetch_file_states(deploy_dir, encryption_key) 92 | for root, _, files in os.walk(build_dir): 93 | relative_path = os.path.relpath(root, build_dir) 94 | server_path = deploy_dir if relative_path == "." else os.path.join(deploy_dir, relative_path.replace("\\", "/")) 95 | if api.create_directory(server_path): 96 | logger.info({"message": "Directory created", "directory": server_path}) 97 | stats["directories_created"] += 1 98 | time.sleep(delay) 99 | else: 100 | logger.info( 101 | { 102 | "message": "Directory skipped", 103 | "directory": server_path, 104 | "reason": "Directory already exists", 105 | } 106 | ) 107 | stats["directories_skipped"] += 1 108 | 109 | for file in files: 110 | if file == "_file_states": 111 | continue 112 | 113 | local_path = os.path.join(root, file) 114 | server_file_path = os.path.join(server_path, file) 115 | local_md5 = compute_md5(local_path) 116 | 117 | if server_file_path not in file_states or local_md5 != file_states.get(server_file_path): 118 | # defines the update method: `upload_file` or `edit_file` 119 | api_method = api.upload_file 120 | if server_file_path in api.get_special_files(): 121 | # some files are special and cannot overwritten / uploaded using the `upload_file` method 122 | api_method = api.edit_file 123 | 124 | if api_method(local_path, server_file_path): 125 | action = "File uploaded" if server_file_path not in file_states else "File updated" 126 | logger.info( 127 | { 128 | "message": action, 129 | "file": server_file_path, 130 | "reason": "MD5 mismatch", 131 | } 132 | ) 133 | file_states[server_file_path] = local_md5 134 | stats["files_uploaded"] += 1 135 | time.sleep(delay) 136 | else: 137 | logger.error({"message": "Failed to upload file", "file": local_path}) 138 | time.sleep(delay) 139 | else: 140 | logger.info( 141 | { 142 | "message": "File skipped", 143 | "file": local_path, 144 | "reason": "MD5 match", 145 | } 146 | ) 147 | stats["files_skipped"] += 1 148 | 149 | file_states_path = os.path.join(build_dir, "_file_states") 150 | if not api.update_file_states(file_states, file_states_path, deploy_dir, encryption_key): 151 | logger.error({"message": "Failed to update remote file states", "file": file_states_path}) 152 | else: 153 | logger.info({"message": "File states updated", "file": file_states_path}) 154 | 155 | logger.info(stats) 156 | 157 | 158 | @app.command() 159 | @handle_errors 160 | def main( 161 | api_key: str = typer.Argument(..., help="Your NekoWeb API key for authentication"), 162 | build_dir: str = typer.Argument(..., help="Directory containing your website build files"), 163 | deploy_dir: str = typer.Argument(..., help="Directory on NekoWeb to deploy to"), 164 | cleanup: str = typer.Argument(..., help="Whether to clean up the deploy directory before deployment"), 165 | nekoweb_pagename: str = typer.Argument( 166 | ..., help="Your NekoWeb page name (your username unless you use a custom domain)" 167 | ), 168 | delay: float = typer.Option(0.0, help="Delay in seconds between each API call (default is 0.0)"), 169 | retry_attempts: int = typer.Option(5, help="Number of times to retry a failed API call (default is 3)"), 170 | retry_delay: float = typer.Option(1.0, help="Delay in seconds between each retry attempt (default is 1.0)"), 171 | retry_exp_backoff: bool = typer.Option( 172 | False, help="Whether to use exponential backoff for retry attempts (default is False)" 173 | ), 174 | encryption_key: str = typer.Option( 175 | None, help="A secret key used to encrypt the file states. Must be a 32-byte URL-safe base64-encoded string" 176 | ), 177 | debug: bool = typer.Option(False, help="Whether to enable debug mode and print tracebacks to the console"), 178 | ): 179 | # setup Requester singleton 180 | Requester(max_retries=retry_attempts, backoff_factor=retry_delay, exponential_backoff=retry_exp_backoff) 181 | 182 | # setup logger and debug 183 | global DEBUG 184 | DEBUG = debug 185 | if DEBUG: 186 | logger.setLevel(logging.DEBUG) 187 | 188 | # initialize 189 | api = NekoWebAPI(api_key, "nekoweb.org", nekoweb_pagename) 190 | if cleanup.lower() == "true": 191 | cleanup_remote_directory(api, deploy_dir) 192 | 193 | deploy(api, build_dir, deploy_dir, encryption_key, delay) 194 | 195 | 196 | if __name__ == "__main__": 197 | app() 198 | --------------------------------------------------------------------------------