|
| 1 | +#!/usr/bin/env python |
| 2 | +# |
| 3 | +# Copyright (c) nexB Inc. and others. All rights reserved. |
| 4 | +# ScanCode is a trademark of nexB Inc. |
| 5 | +# SPDX-License-Identifier: Apache-2.0 |
| 6 | +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. |
| 7 | +# See https://github.com/nexB/scancode-toolkit for support or download. |
| 8 | +# See https://aboutcode.org for more information about nexB OSS projects. |
| 9 | +# |
| 10 | +import hashlib |
| 11 | +import os |
| 12 | +import sys |
| 13 | + |
| 14 | +from pathlib import Path |
| 15 | + |
| 16 | +import click |
| 17 | +import requests |
| 18 | +import utils_thirdparty |
| 19 | + |
| 20 | +from github_release_retry import github_release_retry as grr |
| 21 | + |
| 22 | +""" |
| 23 | +Create GitHub releases and upload files there. |
| 24 | +""" |
| 25 | + |
| 26 | + |
| 27 | +def get_files(location): |
| 28 | + """ |
| 29 | + Return an iterable of (filename, Path, md5) tuples for files in the `location` |
| 30 | + directory tree recursively. |
| 31 | + """ |
| 32 | + for top, _dirs, files in os.walk(location): |
| 33 | + for filename in files: |
| 34 | + pth = Path(os.path.join(top, filename)) |
| 35 | + with open(pth, 'rb') as fi: |
| 36 | + md5 = hashlib.md5(fi.read()).hexdigest() |
| 37 | + yield filename, pth, md5 |
| 38 | + |
| 39 | + |
| 40 | +def get_etag_md5(url): |
| 41 | + """ |
| 42 | + Return the cleaned etag of URL `url` or None. |
| 43 | + """ |
| 44 | + headers = utils_thirdparty.get_remote_headers(url) |
| 45 | + headers = {k.lower(): v for k, v in headers.items()} |
| 46 | + etag = headers .get('etag') |
| 47 | + if etag: |
| 48 | + etag = etag.strip('"').lower() |
| 49 | + return etag |
| 50 | + |
| 51 | + |
| 52 | +def create_or_update_release_and_upload_directory( |
| 53 | + user, |
| 54 | + repo, |
| 55 | + tag_name, |
| 56 | + token, |
| 57 | + directory, |
| 58 | + retry_limit=10, |
| 59 | + description=None, |
| 60 | +): |
| 61 | + """ |
| 62 | + Create or update a GitHub release at https://github.com/<user>/<repo> for |
| 63 | + `tag_name` tag using the optional `description` for this release. |
| 64 | + Use the provided `token` as a GitHub token for API calls authentication. |
| 65 | + Upload all files found in the `directory` tree to that GitHub release. |
| 66 | + Retry API calls up to `retry_limit` time to work around instability the |
| 67 | + GitHub API. |
| 68 | +
|
| 69 | + Remote files that are not the same as the local files are deleted and re- |
| 70 | + uploaded. |
| 71 | + """ |
| 72 | + release_homepage_url = f'https://github.com/{user}/{repo}/releases/{tag_name}' |
| 73 | + |
| 74 | + # scrape release page HTML for links |
| 75 | + urls_by_filename = {os.path.basename(l): l |
| 76 | + for l in utils_thirdparty.get_paths_or_urls(links_url=release_homepage_url) |
| 77 | + } |
| 78 | + |
| 79 | + # compute what is new, modified or unchanged |
| 80 | + print(f'Compute which files is new, modified or unchanged in {release_homepage_url}') |
| 81 | + |
| 82 | + new_to_upload = [] |
| 83 | + unchanged_to_skip = [] |
| 84 | + modified_to_delete_and_reupload = [] |
| 85 | + for filename, pth, md5 in get_files(directory): |
| 86 | + url = urls_by_filename.get(filename) |
| 87 | + if not url: |
| 88 | + print(f'{filename} content is NEW, will upload') |
| 89 | + new_to_upload.append(pth) |
| 90 | + continue |
| 91 | + |
| 92 | + out_of_date = get_etag_md5(url) != md5 |
| 93 | + if out_of_date: |
| 94 | + print(f'{url} content is CHANGED based on md5 etag, will re-upload') |
| 95 | + modified_to_delete_and_reupload.append(pth) |
| 96 | + else: |
| 97 | + # print(f'{url} content is IDENTICAL, skipping upload based on Etag') |
| 98 | + unchanged_to_skip.append(pth) |
| 99 | + print('.') |
| 100 | + |
| 101 | + ghapi = grr.GithubApi( |
| 102 | + github_api_url='https://api.github.com', |
| 103 | + user=user, |
| 104 | + repo=repo, |
| 105 | + token=token, |
| 106 | + retry_limit=retry_limit, |
| 107 | + ) |
| 108 | + |
| 109 | + # yank modified |
| 110 | + print( |
| 111 | + f'Unpublishing {len(modified_to_delete_and_reupload)} published but ' |
| 112 | + f'locally modified files in {release_homepage_url}') |
| 113 | + |
| 114 | + release = ghapi.get_release_by_tag(tag_name) |
| 115 | + |
| 116 | + for pth in modified_to_delete_and_reupload: |
| 117 | + filename = os.path.basename(pth) |
| 118 | + asset_id = ghapi.find_asset_id_by_file_name(filename, release) |
| 119 | + print (f' Unpublishing file: {filename}).') |
| 120 | + response = ghapi.delete_asset(asset_id) |
| 121 | + if response.status_code != requests.codes.no_content: # NOQA |
| 122 | + raise Exception(f'failed asset deletion: {response}') |
| 123 | + |
| 124 | + # finally upload new and modified |
| 125 | + to_upload = new_to_upload + modified_to_delete_and_reupload |
| 126 | + print(f'Publishing with {len(to_upload)} files to {release_homepage_url}') |
| 127 | + release = grr.Release(tag_name=tag_name, body=description) |
| 128 | + grr.make_release(ghapi, release, to_upload) |
| 129 | + |
| 130 | + |
| 131 | +TOKEN_HELP = ( |
| 132 | + 'The Github personal acess token is used to authenticate API calls. ' |
| 133 | + 'Required unless you set the GITHUB_TOKEN environment variable as an alternative. ' |
| 134 | + 'See for details: https://github.com/settings/tokens and ' |
| 135 | + 'https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token' |
| 136 | +) |
| 137 | + |
| 138 | + |
| 139 | +@click.command() |
| 140 | + |
| 141 | +@click.option( |
| 142 | + '--user-repo-tag', |
| 143 | + help='The GitHub qualified repository user/name/tag in which ' |
| 144 | + 'to create the release such as in nexB/thirdparty/pypi', |
| 145 | + type=str, |
| 146 | + required=True, |
| 147 | +) |
| 148 | +@click.option( |
| 149 | + '-d', '--directory', |
| 150 | + help='The directory that contains files to upload to the release.', |
| 151 | + type=click.Path(exists=True, readable=True, path_type=str, file_okay=False, resolve_path=True), |
| 152 | + required=True, |
| 153 | +) |
| 154 | +@click.option( |
| 155 | + '--token', |
| 156 | + help=TOKEN_HELP, |
| 157 | + default=os.environ.get('GITHUB_TOKEN', None), |
| 158 | + type=str, |
| 159 | + required=False, |
| 160 | +) |
| 161 | +@click.option( |
| 162 | + '--description', |
| 163 | + help='Text description for the release. Ignored if the release exists.', |
| 164 | + default=None, |
| 165 | + type=str, |
| 166 | + required=False, |
| 167 | +) |
| 168 | +@click.option( |
| 169 | + '--retry_limit', |
| 170 | + help='Number of retries when making failing GitHub API calls. ' |
| 171 | + 'Retrying helps work around transient failures of the GitHub API.', |
| 172 | + type=int, |
| 173 | + default=10, |
| 174 | +) |
| 175 | +@click.help_option('-h', '--help') |
| 176 | +def publish_files( |
| 177 | + user_repo_tag, |
| 178 | + directory, |
| 179 | + retry_limit=10, token=None, description=None, |
| 180 | +): |
| 181 | + """ |
| 182 | + Publish all the files in DIRECTORY as assets to a GitHub release. |
| 183 | + Either create or update/replace remote files' |
| 184 | + """ |
| 185 | + if not token: |
| 186 | + click.secho('--token required option is missing.') |
| 187 | + click.secho(TOKEN_HELP) |
| 188 | + sys.exit(1) |
| 189 | + |
| 190 | + user, repo, tag_name = user_repo_tag.split('/') |
| 191 | + |
| 192 | + create_or_update_release_and_upload_directory( |
| 193 | + user=user, |
| 194 | + repo=repo, |
| 195 | + tag_name=tag_name, |
| 196 | + description=description, |
| 197 | + retry_limit=retry_limit, |
| 198 | + token=token, |
| 199 | + directory=directory, |
| 200 | + ) |
| 201 | + |
| 202 | + |
| 203 | +if __name__ == '__main__': |
| 204 | + publish_files() |
0 commit comments