From 1b19293ab0787cdcf55c0669e20b1245ff84af4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Fri, 5 Apr 2024 17:34:16 +0200 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=9B=B0=EF=B8=8F=20=20deploy:=20gith?= =?UTF-8?q?ub=20releases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented basic support for using `fdroid delpoy` to upload APKs from the repo to GitHub releases. --- examples/config.yml | 22 ++++++ fdroidserver/deploy.py | 78 +++++++++++++++++++- fdroidserver/github.py | 157 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 fdroidserver/github.py diff --git a/examples/config.yml b/examples/config.yml index 0337e6f0..8efa41ea 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -211,6 +211,28 @@ # - url: https://gitlab.com/user/repo # index_only: true + +# These settings allows using `fdroid deploy` for publishing APK files from +# your repository to GitHub Releases. (You should also run `fdroid update` +# every time before deploying to GitHub releases to update index files.) Here's +# an example for this deployment automation: +# https://github.com/f-droid/fdroidclient/releases/ +# +# It is highly recommended to use a "Fine-grained personal access token", which +# is restriced to the minimum required permissions, which are: +# * Metadata - read +# * Contents - read/write +# Also make sure to limit access only to the GitHub repository you're deploying +# to. (https://github.com/settings/personal-access-tokens/new) +# +# github_releases: +# - repo: f-droid/fdroidclient +# token: {env: GITHUB_TOKEN} +# packages: +# - org.fdroid.basic +# - org.fdroid.fdroid + + # Most git hosting services have hard size limits for each git repo. # `fdroid deploy` will delete the git history when the git mirror repo # approaches this limit to ensure that the repo will still fit when diff --git a/fdroidserver/deploy.py b/fdroidserver/deploy.py index e87703c5..4393d1d7 100644 --- a/fdroidserver/deploy.py +++ b/fdroidserver/deploy.py @@ -31,9 +31,10 @@ import yaml from argparse import ArgumentParser import logging from shlex import split +import pathlib import shutil import git -from pathlib import Path +import fdroidserver.github from . import _ from . import common @@ -663,7 +664,7 @@ def update_servergitmirrors(servergitmirrors, repo_section): return options = common.get_options() - workspace_dir = Path(os.getcwd()) + workspace_dir = pathlib.Path(os.getcwd()) # right now we support only 'repo' git-mirroring if repo_section == 'repo': @@ -1115,6 +1116,76 @@ def push_binary_transparency(git_repo_path, git_remote): raise FDroidException(_("Pushing to remote server failed!")) +def upload_to_github_releases(repo_section, gh_config): + repo_dir = pathlib.Path(repo_section) + idx_path = repo_dir / 'index-v2.json' + if not idx_path.is_file(): + logging.waring( + _( + "Error deploying 'github_releases', {} not present. (You might " + "need to run `fdroid update` first.)" + ).format(idx_path) + ) + return + + known_packages = {} + with open(idx_path, 'r') as f: + idx = json.load(f) + for repo_conf in gh_config: + for package_name in repo_conf.get('packages', []): + package = idx.get('packages', {}).get(package_name, {}) + for version in package.get('versions', {}).values(): + if package_name not in known_packages: + known_packages[package_name] = {} + ver_name = version['manifest']['versionName'] + apk_path = repo_dir / version['file']['name'][1:] + files = [apk_path] + asc_path = pathlib.Path(str(apk_path) + '.asc') + if asc_path.is_file(): + files.append(asc_path) + idsig_path = pathlib.Path(str(apk_path) + '.idsig') + if idsig_path.is_file(): + files.append(idsig_path) + known_packages[package_name][ver_name] = files + + for repo_conf in gh_config: + upload_to_github_releases_repo(repo_conf, known_packages) + + +def upload_to_github_releases_repo(repo_conf, known_packages): + repo = repo_conf.get('repo') + if not repo: + logging.warning(_("One of the 'github_releases' config items is missing the 'repo' value. skipping ...")) + return + token = repo_conf.get('token') + if not token: + logging.warning(_("One of the 'github_releases' config itmes is missing the 'token' value. skipping ...")) + return + packages = repo_conf.get('packages', []) + if not packages: + logging.warning(_("One of the 'github_releases' config itmes is missing the 'packages' value. skipping ...")) + return + + # lookup all versionNames (git tags) for all packages available in the + # local fdroid repo + all_local_versions = set() + for package_name in repo_conf['packages']: + for version in known_packages.get(package_name, {}).keys(): + all_local_versions.add(version) + + gh = fdroidserver.github.GithubApi(token, repo) + unreleased_tags = gh.list_unreleased_tags() + + for version in all_local_versions: + if version in unreleased_tags: + # collect files associated with this github release + files = [] + for package in packages: + files.extend(known_packages.get(package, {}).get(version, [])) + # create new release on github and upload all associated files + gh.create_release(version, files) + + def main(): global config @@ -1194,6 +1265,7 @@ def main(): and not config.get('androidobservatory') and not config.get('binary_transparency_remote') and not config.get('virustotal_apikey') + and not config.get('github_releases') and local_copy_dir is None ): logging.warning( @@ -1236,6 +1308,8 @@ def main(): upload_to_android_observatory(repo_section) if config.get('virustotal_apikey'): upload_to_virustotal(repo_section, config.get('virustotal_apikey')) + if config.get('github_releases'): + upload_to_github_releases(repo_section, config.get('github_releases')) binary_transparency_remote = config.get('binary_transparency_remote') if binary_transparency_remote: diff --git a/fdroidserver/github.py b/fdroidserver/github.py new file mode 100644 index 00000000..6356c4ae --- /dev/null +++ b/fdroidserver/github.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +# +# github.py - part of the FDroid server tools +# Copyright (C) 2024, Michael Pöhn, michael@poehn.at +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json +import pathlib +import urllib.request +import urllib.parse + + +class GithubApi: + """ + Warpper for some select calls to GitHub Json/REST API. + + This class wraps some calls to api.github.com. This is not intended to be a + general API wrapper. Instead it's purpose is to return pre-filtered and + transformed data that's playing well with other fdroidserver functions. + """ + + def __init__(self, api_token, repo_path): + self._api_token = api_token + self._repo_path = repo_path + + def _req(self, url, data=None): + h = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {self._api_token}", + "X-GitHub-Api-Version": "2022-11-28", + } + return urllib.request.Request( + url, + headers=h, + data=data, + ) + + def list_released_tags(self): + """List of all tags that are associated with a release for this repo on GitHub.""" + names = [] + req = self._req(f"https://api.github.com/repos/{self._repo_path}/releases") + with urllib.request.urlopen(req) as resp: + releases = json.load(resp) + for release in releases: + names.append(release['tag_name']) + return names + + def list_unreleased_tags(self): + all_tags = self.list_all_tags() + released_tags = self.list_released_tags() + return [x for x in all_tags if x not in released_tags] + + def tag_exists(self, tag): + """ + Check if git tag is present on github. + + https://docs.github.com/en/rest/git/refs?apiVersion=2022-11-28#list-matching-references--fine-grained-access-tokens + """ + req = self._req( + f"https://api.github.com/repos/{self._repo_path}/git/matching-refs/tags/{tag}" + ) + with urllib.request.urlopen(req) as resp: + rd = json.load(resp) + return len(rd) == 1 and rd[0].get("ref", False) == f"refs/tags/{tag}" + return False + + def list_all_tags(self): + """Get list of all tags for this repo on GitHub.""" + tags = [] + req = self._req( + f"https://api.github.com/repos/{self._repo_path}/git/matching-refs/tags/" + ) + with urllib.request.urlopen(req) as resp: + refs = json.load(resp) + for ref in refs: + r = ref['ref'] + if r.startswith('refs/tags/'): + tags.append(r[10:]) + return tags + + def create_release(self, tag, files): + """ + Create a new release on github. + + also see: https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#create-a-release + + :returns: True if release was created, False if release already exists + :raises: urllib exceptions in case of network or api errors, also + raises an exception when the tag doesn't exists. + """ + # Querying github to create a new release for a non-existent tag, will + # also create that tag on github. So we need an additional check to + # prevent this behavior. + if not self.tag_exists(tag): + raise Exception( + f"can't create github release for {self._repo_path} {tag}, tag doesn't exists" + ) + # create the relase on github + req = self._req( + f"https://api.github.com/repos/{self._repo_path}/releases", + data=json.dumps( + { + "tag_name": tag, + } + ).encode("utf-8"), + ) + try: + with urllib.request.urlopen(req) as resp: + release_id = json.load(resp)['id'] + except urllib.error.HTTPError as e: + if e.status == 422: + codes = [x['code'] for x in json.load(e).get('errors', [])] + if "already_exists" in codes: + return False + raise e + + # attach / upload all files for the relase + for file in files: + self._create_release_asset(release_id, file) + + return True + + def _create_release_asset(self, release_id, file): + """ + Attach a file to a release on GitHub. + + This uploads a file to github relases, it will be attached to the supplied release + + also see: https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#upload-a-release-asset + """ + file = pathlib.Path(file) + with open(file, 'rb') as f: + req = urllib.request.Request( + f"https://uploads.github.com/repos/{self._repo_path}/releases/{release_id}/assets?name={file.name}", + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {self._api_token}", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/octet-stream", + }, + data=f.read(), + ) + with urllib.request.urlopen(req): + return True + return False From 44b0af933d5dc66e23a6b191bf48b8eb87558c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Tue, 16 Apr 2024 11:35:54 +0200 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=8E=A1=20add=20unit=20tests=20for?= =?UTF-8?q?=20github.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add unittests for our github api calls --- fdroidserver/github.py | 2 +- tests/github.TestCase | 164 +++++++++++++++++++++++++++++++++++++++++ tests/testcommon.py | 12 ++- 3 files changed, 175 insertions(+), 3 deletions(-) create mode 100755 tests/github.TestCase diff --git a/fdroidserver/github.py b/fdroidserver/github.py index 6356c4ae..b7a8ce2a 100644 --- a/fdroidserver/github.py +++ b/fdroidserver/github.py @@ -85,7 +85,7 @@ class GithubApi: with urllib.request.urlopen(req) as resp: refs = json.load(resp) for ref in refs: - r = ref['ref'] + r = ref.get('ref', '') if r.startswith('refs/tags/'): tags.append(r[10:]) return tags diff --git a/tests/github.TestCase b/tests/github.TestCase new file mode 100755 index 00000000..bc5e04a4 --- /dev/null +++ b/tests/github.TestCase @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 + +import inspect +import optparse +import os +import sys +import unittest.mock +import testcommon + +localmodule = os.path.realpath( + os.path.join(os.path.dirname(inspect.getfile(inspect.currentframe())), '..') +) +print('localmodule: ' + localmodule) +if localmodule not in sys.path: + sys.path.insert(0, localmodule) + +import fdroidserver.github +import fdroidserver.common + + +class GithubApiTest(unittest.TestCase): + def test__init(self): + api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath') + self.assertEqual(api._api_token, 'faketoken') + self.assertEqual(api._repo_path, 'fakerepopath') + + def test__req(self): + api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath') + r = api._req('https://fakeurl', data='fakedata') + self.assertEqual(r.full_url, 'https://fakeurl') + self.assertEqual(r.data, "fakedata") + self.assertDictEqual( + r.headers, + { + 'Accept': 'application/vnd.github+json', + 'Authorization': 'Bearer faketoken', + 'X-github-api-version': '2022-11-28', + }, + ) + + def test_list_released_tags(self): + api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath') + uomock = testcommon.mock_urlopen( + body='[{"tag_name": "fake"}, {"tag_name": "double_fake"}]' + ) + with unittest.mock.patch("urllib.request.urlopen", uomock): + result = api.list_released_tags() + self.assertListEqual(result, ['fake', 'double_fake']) + + def test_list_unreleased_tags(self): + api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath') + + api.list_all_tags = unittest.mock.Mock(return_value=[1, 2, 3, 4]) + api.list_released_tags = unittest.mock.Mock(return_value=[1, 2]) + + result = api.list_unreleased_tags() + + self.assertListEqual(result, [3, 4]) + + def test_tag_exists(self): + api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath') + uomock = testcommon.mock_urlopen(body='[{"ref": "refs/tags/fake_tag"}]') + with unittest.mock.patch("urllib.request.urlopen", uomock): + result = api.tag_exists('fake_tag') + self.assertTrue(result) + + def test_tag_exists_failure(self): + api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath') + + uomock = testcommon.mock_urlopen(body='[{"error": "failure"}]') + + with unittest.mock.patch("urllib.request.urlopen", uomock): + success = api.tag_exists('fake_tag') + + self.assertFalse(success) + + def test_list_all_tags(self): + api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath') + + uomock = testcommon.mock_urlopen( + body='[{"ref": "refs/tags/fake"}, {"ref": "refs/tags/double_fake"}]' + ) + + with unittest.mock.patch("urllib.request.urlopen", uomock): + result = api.list_all_tags() + + self.assertListEqual(result, ['fake', 'double_fake']) + + def test_create_release(self): + api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath') + + uomock = testcommon.mock_urlopen(body='{"id": "fakeid"}') + api.tag_exists = lambda x: True + api._create_release_asset = unittest.mock.Mock() + + with unittest.mock.patch("urllib.request.urlopen", uomock): + success = api.create_release('faketag', ['file_a', 'file_b']) + self.assertTrue(success) + + req = uomock.call_args_list[0][0][0] + self.assertEqual(1, len(uomock.call_args_list)) + self.assertEqual(2, len(uomock.call_args_list[0])) + self.assertEqual(1, len(uomock.call_args_list[0][0])) + self.assertEqual( + req.full_url, + 'https://api.github.com/repos/fakerepopath/releases', + ) + self.assertEqual(req.data, b'{"tag_name": "faketag"}') + self.assertListEqual( + api._create_release_asset.call_args_list, + [ + unittest.mock.call('fakeid', 'file_a'), + unittest.mock.call('fakeid', 'file_b'), + ], + ) + + def test__create_release_asset(self): + api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath') + uomock = testcommon.mock_urlopen() + + with unittest.mock.patch( + 'fdroidserver.github.open', + unittest.mock.mock_open(read_data=b"fake_content"), + ), unittest.mock.patch("urllib.request.urlopen", uomock): + success = api._create_release_asset('fake_id', 'fake_file') + + self.assertTrue(success) + + req = uomock.call_args_list[0][0][0] + self.assertEqual(1, len(uomock.call_args_list)) + self.assertEqual(2, len(uomock.call_args_list[0])) + self.assertEqual(1, len(uomock.call_args_list[0][0])) + self.assertEqual( + req.full_url, + 'https://uploads.github.com/repos/fakerepopath/releases/fake_id/assets?name=fake_file', + ) + self.assertDictEqual( + req.headers, + { + "Accept": "application/vnd.github+json", + 'Authorization': 'Bearer faketoken', + 'Content-type': 'application/octet-stream', + 'X-github-api-version': '2022-11-28', + }, + ) + self.assertEqual(req.data, b'fake_content') + + +if __name__ == "__main__": + os.chdir(os.path.dirname(__file__)) + + parser = optparse.OptionParser() + parser.add_option( + "-v", + "--verbose", + action="store_true", + default=False, + help="Spew out even more information than normal", + ) + (fdroidserver.common.options, args) = parser.parse_args(["--verbose"]) + + newSuite = unittest.TestSuite() + newSuite.addTest(unittest.makeSuite(GithubApiTest)) + unittest.main(failfast=False) diff --git a/tests/testcommon.py b/tests/testcommon.py index 2ce9f393..f0fd11bd 100644 --- a/tests/testcommon.py +++ b/tests/testcommon.py @@ -19,9 +19,9 @@ import os import sys import tempfile import unittest +import unittest.mock from pathlib import Path -from unittest import mock class TmpCwd: @@ -84,5 +84,13 @@ def parse_args_for_test(parser, args): for arg in args: if arg[0] == '-': flags.append(flags) - with mock.patch('sys.argv', flags): + with unittest.mock.patch('sys.argv', flags): parse_args(parser) + + +def mock_urlopen(status=200, body=None): + resp = unittest.mock.MagicMock() + resp.getcode.return_value = status + resp.read.return_value = body + resp.__enter__.return_value = resp + return unittest.mock.Mock(return_value=resp) From 7a6fa7f81662f37c7dc0f00e324d4e53f0bda508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Tue, 16 Apr 2024 12:57:22 +0200 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=9B=84=20deploy:=20separate=20funct?= =?UTF-8?q?ion=20for=20release=20file=20lookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fdroidserver/deploy.py | 73 +++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 26 deletions(-) diff --git a/fdroidserver/deploy.py b/fdroidserver/deploy.py index 4393d1d7..7f7fe2ef 100644 --- a/fdroidserver/deploy.py +++ b/fdroidserver/deploy.py @@ -1116,43 +1116,64 @@ def push_binary_transparency(git_repo_path, git_remote): raise FDroidException(_("Pushing to remote server failed!")) +def find_release_files(index_v2_path, repo_dir, package_names): + """ + Find files for uploading to a release page. + + This function parses index-v2.json for file-paths elegible for deployment + to release pages. (e.g. GitHub releases) It also groups these files by + packageName and versionName. e.g. to get a list of files for all specific + release of fdroid client you may call: + + find_binary_release_files()['org.fdroid.fdroid']['0.19.2'] + + All paths in the returned data-structure are of type pathlib.Path. + """ + release_files = {} + with open(index_v2_path, 'r') as f: + idx = json.load(f) + for package_name in package_names: + package = idx.get('packages', {}).get(package_name, {}) + for version in package.get('versions', {}).values(): + if package_name not in release_files: + release_files[package_name] = {} + ver_name = version['manifest']['versionName'] + apk_path = repo_dir / version['file']['name'][1:] + files = [apk_path] + asc_path = pathlib.Path(str(apk_path) + '.asc') + if asc_path.is_file(): + files.append(asc_path) + idsig_path = pathlib.Path(str(apk_path) + '.idsig') + if idsig_path.is_file(): + files.append(idsig_path) + release_files[package_name][ver_name] = files + return release_files + + def upload_to_github_releases(repo_section, gh_config): repo_dir = pathlib.Path(repo_section) - idx_path = repo_dir / 'index-v2.json' - if not idx_path.is_file(): + index_v2_path = repo_dir / 'index-v2.json' + if not index_v2_path.is_file(): logging.waring( _( "Error deploying 'github_releases', {} not present. (You might " "need to run `fdroid update` first.)" - ).format(idx_path) + ).format(index_v2_path) ) return - known_packages = {} - with open(idx_path, 'r') as f: - idx = json.load(f) - for repo_conf in gh_config: - for package_name in repo_conf.get('packages', []): - package = idx.get('packages', {}).get(package_name, {}) - for version in package.get('versions', {}).values(): - if package_name not in known_packages: - known_packages[package_name] = {} - ver_name = version['manifest']['versionName'] - apk_path = repo_dir / version['file']['name'][1:] - files = [apk_path] - asc_path = pathlib.Path(str(apk_path) + '.asc') - if asc_path.is_file(): - files.append(asc_path) - idsig_path = pathlib.Path(str(apk_path) + '.idsig') - if idsig_path.is_file(): - files.append(idsig_path) - known_packages[package_name][ver_name] = files + package_names = [] + for repo_conf in gh_config: + for package_name in repo_conf.get('packages', []): + package_names.append(package_name) + + release_files = find_release_files(index_v2_path, repo_dir, package_names) for repo_conf in gh_config: - upload_to_github_releases_repo(repo_conf, known_packages) + upload_to_github_releases_repo(repo_conf, release_files) -def upload_to_github_releases_repo(repo_conf, known_packages): +def upload_to_github_releases_repo(repo_conf, release_files): repo = repo_conf.get('repo') if not repo: logging.warning(_("One of the 'github_releases' config items is missing the 'repo' value. skipping ...")) @@ -1170,7 +1191,7 @@ def upload_to_github_releases_repo(repo_conf, known_packages): # local fdroid repo all_local_versions = set() for package_name in repo_conf['packages']: - for version in known_packages.get(package_name, {}).keys(): + for version in release_files.get(package_name, {}).keys(): all_local_versions.add(version) gh = fdroidserver.github.GithubApi(token, repo) @@ -1181,7 +1202,7 @@ def upload_to_github_releases_repo(repo_conf, known_packages): # collect files associated with this github release files = [] for package in packages: - files.extend(known_packages.get(package, {}).get(version, [])) + files.extend(release_files.get(package, {}).get(version, [])) # create new release on github and upload all associated files gh.create_release(version, files) From a079f9d85f084928d025630e08efea2f215b38c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Tue, 16 Apr 2024 13:12:29 +0200 Subject: [PATCH 04/10] =?UTF-8?q?=E2=99=9F=EF=B8=8F=20deploy:=20add=20glob?= =?UTF-8?q?al=20config=20`github=5Ftoken`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/config.yml | 6 +++++- fdroidserver/deploy.py | 10 +++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/examples/config.yml b/examples/config.yml index 8efa41ea..9fa8fa6d 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -225,12 +225,16 @@ # Also make sure to limit access only to the GitHub repository you're deploying # to. (https://github.com/settings/personal-access-tokens/new) # +# github_token: {env: GITHUB_TOKEN} # github_releases: # - repo: f-droid/fdroidclient -# token: {env: GITHUB_TOKEN} # packages: # - org.fdroid.basic # - org.fdroid.fdroid +# - repo: example/app +# token: {env: GITHUB_TOKEN_EXAMPLE} +# packages: +# - com.example.app # Most git hosting services have hard size limits for each git repo. diff --git a/fdroidserver/deploy.py b/fdroidserver/deploy.py index 7f7fe2ef..0ed65c7b 100644 --- a/fdroidserver/deploy.py +++ b/fdroidserver/deploy.py @@ -1150,7 +1150,7 @@ def find_release_files(index_v2_path, repo_dir, package_names): return release_files -def upload_to_github_releases(repo_section, gh_config): +def upload_to_github_releases(repo_section, gh_config, global_gh_token): repo_dir = pathlib.Path(repo_section) index_v2_path = repo_dir / 'index-v2.json' if not index_v2_path.is_file(): @@ -1170,15 +1170,15 @@ def upload_to_github_releases(repo_section, gh_config): release_files = find_release_files(index_v2_path, repo_dir, package_names) for repo_conf in gh_config: - upload_to_github_releases_repo(repo_conf, release_files) + upload_to_github_releases_repo(repo_conf, release_files, global_gh_token) -def upload_to_github_releases_repo(repo_conf, release_files): +def upload_to_github_releases_repo(repo_conf, release_files, global_gh_token): repo = repo_conf.get('repo') if not repo: logging.warning(_("One of the 'github_releases' config items is missing the 'repo' value. skipping ...")) return - token = repo_conf.get('token') + token = repo_conf.get('token') or global_gh_token if not token: logging.warning(_("One of the 'github_releases' config itmes is missing the 'token' value. skipping ...")) return @@ -1330,7 +1330,7 @@ def main(): if config.get('virustotal_apikey'): upload_to_virustotal(repo_section, config.get('virustotal_apikey')) if config.get('github_releases'): - upload_to_github_releases(repo_section, config.get('github_releases')) + upload_to_github_releases(repo_section, config.get('github_releases'), config.get('github_token')) binary_transparency_remote = config.get('binary_transparency_remote') if binary_transparency_remote: From c6598f2835ec31079c57c73c69bd6cc163713b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Wed, 17 Apr 2024 21:04:45 +0200 Subject: [PATCH 05/10] =?UTF-8?q?=E2=98=84=EF=B8=8F=20=20deploy:=20github?= =?UTF-8?q?=20releases=20-=20whatsNew=20text=20as=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use whatsNew text (if available) as release notes text when deploying to Github releases. This feature will always use 'en-US' locale texts, since English is the lingua franka on GitHub. Additionally this change also adds a config option to preprend a static text to those release notes. --- examples/config.yml | 11 +++++++---- fdroidserver/deploy.py | 36 ++++++++++++++++++++++-------------- fdroidserver/github.py | 3 ++- tests/github.TestCase | 4 ++-- 4 files changed, 33 insertions(+), 21 deletions(-) diff --git a/examples/config.yml b/examples/config.yml index 9fa8fa6d..2eb50abc 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -218,12 +218,13 @@ # an example for this deployment automation: # https://github.com/f-droid/fdroidclient/releases/ # -# It is highly recommended to use a "Fine-grained personal access token", which -# is restriced to the minimum required permissions, which are: +# In the examble below tokens are read from environment variables. Putting +# tokens directly into the config file is also supported but discouraged. It is +# highly recommended to use a "Fine-grained personal access token", which is +# restriced to the minimum required permissions, which are: # * Metadata - read # * Contents - read/write -# Also make sure to limit access only to the GitHub repository you're deploying -# to. (https://github.com/settings/personal-access-tokens/new) +# (https://github.com/settings/personal-access-tokens/new) # # github_token: {env: GITHUB_TOKEN} # github_releases: @@ -231,6 +232,8 @@ # packages: # - org.fdroid.basic # - org.fdroid.fdroid +# release_notes_prepend: | +# Re-post of official F-Droid App release from https://f-droid.org # - repo: example/app # token: {env: GITHUB_TOKEN_EXAMPLE} # packages: diff --git a/fdroidserver/deploy.py b/fdroidserver/deploy.py index 0ed65c7b..85a4b109 100644 --- a/fdroidserver/deploy.py +++ b/fdroidserver/deploy.py @@ -1116,27 +1116,27 @@ def push_binary_transparency(git_repo_path, git_remote): raise FDroidException(_("Pushing to remote server failed!")) -def find_release_files(index_v2_path, repo_dir, package_names): +def find_release_infos(index_v2_path, repo_dir, package_names): """ - Find files for uploading to a release page. + Find files, texts, etc. for uploading to a release page. This function parses index-v2.json for file-paths elegible for deployment to release pages. (e.g. GitHub releases) It also groups these files by packageName and versionName. e.g. to get a list of files for all specific release of fdroid client you may call: - find_binary_release_files()['org.fdroid.fdroid']['0.19.2'] + find_binary_release_infos()['org.fdroid.fdroid']['0.19.2'] All paths in the returned data-structure are of type pathlib.Path. """ - release_files = {} + release_infos = {} with open(index_v2_path, 'r') as f: idx = json.load(f) for package_name in package_names: package = idx.get('packages', {}).get(package_name, {}) for version in package.get('versions', {}).values(): - if package_name not in release_files: - release_files[package_name] = {} + if package_name not in release_infos: + release_infos[package_name] = {} ver_name = version['manifest']['versionName'] apk_path = repo_dir / version['file']['name'][1:] files = [apk_path] @@ -1146,8 +1146,11 @@ def find_release_files(index_v2_path, repo_dir, package_names): idsig_path = pathlib.Path(str(apk_path) + '.idsig') if idsig_path.is_file(): files.append(idsig_path) - release_files[package_name][ver_name] = files - return release_files + release_infos[package_name][ver_name] = { + 'files': files, + 'whatsNew': version.get('whatsNew', {}).get("en-US"), + } + return release_infos def upload_to_github_releases(repo_section, gh_config, global_gh_token): @@ -1167,13 +1170,13 @@ def upload_to_github_releases(repo_section, gh_config, global_gh_token): for package_name in repo_conf.get('packages', []): package_names.append(package_name) - release_files = find_release_files(index_v2_path, repo_dir, package_names) + release_infos = find_release_infos(index_v2_path, repo_dir, package_names) for repo_conf in gh_config: - upload_to_github_releases_repo(repo_conf, release_files, global_gh_token) + upload_to_github_releases_repo(repo_conf, release_infos, global_gh_token) -def upload_to_github_releases_repo(repo_conf, release_files, global_gh_token): +def upload_to_github_releases_repo(repo_conf, release_infos, global_gh_token): repo = repo_conf.get('repo') if not repo: logging.warning(_("One of the 'github_releases' config items is missing the 'repo' value. skipping ...")) @@ -1191,7 +1194,7 @@ def upload_to_github_releases_repo(repo_conf, release_files, global_gh_token): # local fdroid repo all_local_versions = set() for package_name in repo_conf['packages']: - for version in release_files.get(package_name, {}).keys(): + for version in release_infos.get(package_name, {}).keys(): all_local_versions.add(version) gh = fdroidserver.github.GithubApi(token, repo) @@ -1202,9 +1205,14 @@ def upload_to_github_releases_repo(repo_conf, release_files, global_gh_token): # collect files associated with this github release files = [] for package in packages: - files.extend(release_files.get(package, {}).get(version, [])) + files.extend(release_infos.get(package, {}).get(version, {}).get('files', [])) + # always use the whatsNew text from the first app listed in + # config.qml github_releases.packages + text = release_infos.get(packages[0], {}).get(version, {}).get('whatsNew') or '' + if 'release_notes_prepend' in repo_conf: + text = repo_conf['release_notes_prepend'] + "\n\n" + text # create new release on github and upload all associated files - gh.create_release(version, files) + gh.create_release(version, files, text) def main(): diff --git a/fdroidserver/github.py b/fdroidserver/github.py index b7a8ce2a..68396235 100644 --- a/fdroidserver/github.py +++ b/fdroidserver/github.py @@ -90,7 +90,7 @@ class GithubApi: tags.append(r[10:]) return tags - def create_release(self, tag, files): + def create_release(self, tag, files, body=''): """ Create a new release on github. @@ -113,6 +113,7 @@ class GithubApi: data=json.dumps( { "tag_name": tag, + "body": body, } ).encode("utf-8"), ) diff --git a/tests/github.TestCase b/tests/github.TestCase index bc5e04a4..608d7215 100755 --- a/tests/github.TestCase +++ b/tests/github.TestCase @@ -94,7 +94,7 @@ class GithubApiTest(unittest.TestCase): api._create_release_asset = unittest.mock.Mock() with unittest.mock.patch("urllib.request.urlopen", uomock): - success = api.create_release('faketag', ['file_a', 'file_b']) + success = api.create_release('faketag', ['file_a', 'file_b'], body="bdy") self.assertTrue(success) req = uomock.call_args_list[0][0][0] @@ -105,7 +105,7 @@ class GithubApiTest(unittest.TestCase): req.full_url, 'https://api.github.com/repos/fakerepopath/releases', ) - self.assertEqual(req.data, b'{"tag_name": "faketag"}') + self.assertEqual(req.data, b'{"tag_name": "faketag", "body": "bdy"}') self.assertListEqual( api._create_release_asset.call_args_list, [ From 242490ddc3a99fd3d86a1a8492964a4791331e77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Mon, 22 Apr 2024 19:40:08 +0200 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=8E=AF=20deploy:=20no=20releaseChan?= =?UTF-8?q?nels=20on=20github=20releases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Don't deploy versions of to GitHub releases where a `releaseChannels` value is set in index-v2.json. (This usually would mean it's a alpha or beta version.) --- examples/config.yml | 3 +++ fdroidserver/deploy.py | 29 +++++++++++++++++------------ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/examples/config.yml b/examples/config.yml index 2eb50abc..3cfc17c5 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -218,6 +218,9 @@ # an example for this deployment automation: # https://github.com/f-droid/fdroidclient/releases/ # +# Currently versions which are assigned to a release channel (e.g. alpha or +# beta releases) are ignored. +# # In the examble below tokens are read from environment variables. Putting # tokens directly into the config file is also supported but discouraged. It is # highly recommended to use a "Fine-grained personal access token", which is diff --git a/fdroidserver/deploy.py b/fdroidserver/deploy.py index 85a4b109..b326cf95 100644 --- a/fdroidserver/deploy.py +++ b/fdroidserver/deploy.py @@ -1118,7 +1118,7 @@ def push_binary_transparency(git_repo_path, git_remote): def find_release_infos(index_v2_path, repo_dir, package_names): """ - Find files, texts, etc. for uploading to a release page. + Find files, texts, etc. for uploading to a release page in index-v2.json. This function parses index-v2.json for file-paths elegible for deployment to release pages. (e.g. GitHub releases) It also groups these files by @@ -1149,6 +1149,7 @@ def find_release_infos(index_v2_path, repo_dir, package_names): release_infos[package_name][ver_name] = { 'files': files, 'whatsNew': version.get('whatsNew', {}).get("en-US"), + 'hasReleaseChannels': len(version.get('releaseChannels', [])) > 0, } return release_infos @@ -1202,17 +1203,21 @@ def upload_to_github_releases_repo(repo_conf, release_infos, global_gh_token): for version in all_local_versions: if version in unreleased_tags: - # collect files associated with this github release - files = [] - for package in packages: - files.extend(release_infos.get(package, {}).get(version, {}).get('files', [])) - # always use the whatsNew text from the first app listed in - # config.qml github_releases.packages - text = release_infos.get(packages[0], {}).get(version, {}).get('whatsNew') or '' - if 'release_notes_prepend' in repo_conf: - text = repo_conf['release_notes_prepend'] + "\n\n" + text - # create new release on github and upload all associated files - gh.create_release(version, files, text) + # Making sure we're not uploading this version when releaseChannels + # is set. (releaseChannels usually mean it's e.g. an alpha or beta + # version) + if not release_infos.get(packages[0], {}).get(version, {}).get('hasReleaseChannels'): + # collect files associated with this github release + files = [] + for package in packages: + files.extend(release_infos.get(package, {}).get(version, {}).get('files', [])) + # always use the whatsNew text from the first app listed in + # config.qml github_releases.packages + text = release_infos.get(packages[0], {}).get(version, {}).get('whatsNew') or '' + if 'release_notes_prepend' in repo_conf: + text = repo_conf['release_notes_prepend'] + "\n\n" + text + # create new release on github and upload all associated files + gh.create_release(version, files, text) def main(): From a87284cf80768eae14177359cdf6ab7040987d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Tue, 23 Apr 2024 14:14:05 +0200 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=9A=A5=20deploy:=20tests=20for=20gi?= =?UTF-8?q?thub=20releases=20deploy=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test cases for github releases function in deploy.py --- fdroidserver/deploy.py | 9 +- tests/deploy.TestCase | 255 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 261 insertions(+), 3 deletions(-) diff --git a/fdroidserver/deploy.py b/fdroidserver/deploy.py index b326cf95..b8c0d711 100644 --- a/fdroidserver/deploy.py +++ b/fdroidserver/deploy.py @@ -1138,7 +1138,10 @@ def find_release_infos(index_v2_path, repo_dir, package_names): if package_name not in release_infos: release_infos[package_name] = {} ver_name = version['manifest']['versionName'] - apk_path = repo_dir / version['file']['name'][1:] + apk_path = version['file']['name'] + if apk_path.startswith('/'): + apk_path = apk_path[1:] + apk_path = repo_dir / apk_path files = [apk_path] asc_path = pathlib.Path(str(apk_path) + '.asc') if asc_path.is_file(): @@ -1158,7 +1161,7 @@ def upload_to_github_releases(repo_section, gh_config, global_gh_token): repo_dir = pathlib.Path(repo_section) index_v2_path = repo_dir / 'index-v2.json' if not index_v2_path.is_file(): - logging.waring( + logging.warning( _( "Error deploying 'github_releases', {} not present. (You might " "need to run `fdroid update` first.)" @@ -1171,7 +1174,7 @@ def upload_to_github_releases(repo_section, gh_config, global_gh_token): for package_name in repo_conf.get('packages', []): package_names.append(package_name) - release_infos = find_release_infos(index_v2_path, repo_dir, package_names) + release_infos = fdroidserver.deploy.find_release_infos(index_v2_path, repo_dir, package_names) for repo_conf in gh_config: upload_to_github_releases_repo(repo_conf, release_infos, global_gh_token) diff --git a/tests/deploy.TestCase b/tests/deploy.TestCase index fa87f7b6..041f0eaf 100755 --- a/tests/deploy.TestCase +++ b/tests/deploy.TestCase @@ -1210,6 +1210,259 @@ class DeployTest(unittest.TestCase): ) +class GitHubReleasesTest(unittest.TestCase): + + def test_find_release_infos(self): + self.maxDiff = None + + index_mock = b""" + { + "packages": { + "com.example.app": { + "versions": { + "2e6f263c1927506015bfc98bce0818247836f2e7fe29a04e1af2b33c97848750": { + "file": { + "name": "/com.example.app_123.apk" + }, + "whatsNew": { + "en-US": "fake what's new" + }, + "manifest": { + "versionName": "1.2.3", + "versionCode": "123" + } + }, + "8a6f263c8327506015bfc98bce0815247836f2e7fe29a04e1af2bffa6409998d": { + "file": { + "name": "/com.example.app_100.apk" + }, + "manifest": { + "versionName": "1.0-alpha", + "versionCode": "123" + }, + "releaseChannels": ["alpha"] + } + } + }, + "another.app": { + "versions": { + "30602ffc19a7c0601bbfa93bce00082c78a6f2ddfe29a04e1af253fc9f84eda0": { + "file": { + "name": "/another.app_1.apk" + }, + "manifest": { + "versionName": "1", + "versionCode": "1" + } + } + } + }, + "fildered.app": { + "versions": { + "93ae02fc19a7c0601adfa93bce0443fc78a6f2ddfe3df04e1af093fca9a1ff09": { + "file": { + "name": "/another.app_1.apk" + }, + "manifest": { + "versionName": "1", + "versionCode": "1" + } + } + } + } + } + } + """ + with unittest.mock.patch( + "fdroidserver.deploy.open", unittest.mock.mock_open(read_data=index_mock) + ): + release_infos = fdroidserver.deploy.find_release_infos( + "fake_path", + Path('fake_repo'), + ["com.example.app", "another.app"], + ) + + self.assertDictEqual( + release_infos, + { + "another.app": { + "1": { + "files": [Path('fake_repo') / "another.app_1.apk"], + "hasReleaseChannels": False, + "whatsNew": None, + }, + }, + "com.example.app": { + "1.0-alpha": { + "files": [ + Path("fake_repo") / "com.example.app_100.apk", + ], + "hasReleaseChannels": True, + "whatsNew": None, + }, + "1.2.3": { + "files": [ + Path("fake_repo") / "com.example.app_123.apk", + ], + "hasReleaseChannels": False, + "whatsNew": "fake what's new", + }, + }, + }, + ) + + def test_upload_to_github_releases(self): + gh_config = [ + { + "repo": "example/app", + "packages": ["com.example.app", "another.app"], + }, + { + "repo": "custom/app", + "packages": ["more.custom.app"], + "token": "custom_token", + }, + ] + + fri_mock = unittest.mock.Mock(return_value="fri_result") + urr_mock = unittest.mock.Mock() + with unittest.mock.patch( + "fdroidserver.deploy.find_release_infos", fri_mock + ), unittest.mock.patch( + "fdroidserver.deploy.upload_to_github_releases_repo", urr_mock + ), tempfile.TemporaryDirectory() as tmpdir: + + with open(Path(tmpdir) / "index-v2.json", "w") as f: + f.write("") + + fdroidserver.deploy.upload_to_github_releases( + tmpdir, gh_config, "fake_global_token" + ) + + fri_mock.assert_called_once_with( + Path(tmpdir) / "index-v2.json", + Path(tmpdir), + ["com.example.app", "another.app", "more.custom.app"], + ) + + self.assertListEqual( + urr_mock.call_args_list, + [ + unittest.mock.call( + { + "repo": "example/app", + "packages": ["com.example.app", "another.app"], + }, + "fri_result", + "fake_global_token", + ), + unittest.mock.call( + { + "repo": "custom/app", + "packages": ["more.custom.app"], + "token": "custom_token", + }, + "fri_result", + "fake_global_token", + ), + ], + ) + + +class Test_UploadToGithubReleasesRepo(unittest.TestCase): + + def setUp(self): + self.repo_conf = { + "repo": "example/app", + "packages": ["com.example.app", "com.example.altapp", "another.app"], + } + self.release_infos = { + "com.example.app": { + "1.0.0": { + "files": [ + Path("fake_repo") / "com.example.app_100100.apk", + ], + "hasReleaseChannels": False, + "whatsNew": "what's new com.example.app 1.0.0", + }, + "1.0.0-beta1": { + "files": [ + Path("fake_repo") / "com.example.app_100007.apk", + ], + "hasReleaseChannels": True, + "whatsNew": None, + }, + }, + "com.example.altapp": { + "1.0.0": { + "files": [ + Path("fake_repo") / "com.example.altapp_100100.apk", + Path("fake_repo") / "com.example.altapp_100100.apk.asc", + Path("fake_repo") / "com.example.altapp_100100.apk.idsig", + ], + "whatsNew": "what's new com.example.altapp 1.0.0", + }, + }, + } + + self.api = unittest.mock.Mock() + self.api.list_unreleased_tags = lambda: ["1.0.0", "1.0.0-beta1"] + self.api_constructor = unittest.mock.Mock(return_value=self.api) + + def test_global_token(self): + with unittest.mock.patch("fdroidserver.github.GithubApi", self.api_constructor): + fdroidserver.deploy.upload_to_github_releases_repo( + self.repo_conf, + self.release_infos, + "global_token", + ) + + self.api_constructor.assert_called_once_with("global_token", "example/app") + + self.assertListEqual( + self.api.create_release.call_args_list, + [ + unittest.mock.call( + "1.0.0", + [ + Path("fake_repo/com.example.app_100100.apk"), + Path("fake_repo/com.example.altapp_100100.apk"), + Path("fake_repo/com.example.altapp_100100.apk.asc"), + Path("fake_repo/com.example.altapp_100100.apk.idsig"), + ], + "what's new com.example.app 1.0.0", + ), + ], + ) + + def test_local_token(self): + self.repo_conf["token"] = "local_token" + with unittest.mock.patch("fdroidserver.github.GithubApi", self.api_constructor): + fdroidserver.deploy.upload_to_github_releases_repo( + self.repo_conf, + self.release_infos, + "global_token", + ) + + self.api_constructor.assert_called_once_with("local_token", "example/app") + + self.assertListEqual( + self.api.create_release.call_args_list, + [ + unittest.mock.call( + "1.0.0", + [ + Path("fake_repo/com.example.app_100100.apk"), + Path("fake_repo/com.example.altapp_100100.apk"), + Path("fake_repo/com.example.altapp_100100.apk.asc"), + Path("fake_repo/com.example.altapp_100100.apk.idsig"), + ], + "what's new com.example.app 1.0.0", + ), + ], + ) + + if __name__ == "__main__": os.chdir(os.path.dirname(__file__)) @@ -1227,4 +1480,6 @@ if __name__ == "__main__": newSuite = unittest.TestSuite() newSuite.addTest(unittest.makeSuite(DeployTest)) + newSuite.addTest(unittest.makeSuite(GitHubReleasesTest)) + newSuite.addTest(unittest.makeSuite(Test_UploadToGithubReleasesRepo)) unittest.main(failfast=False) From aca98c1355e7bc38601d33d9c20f9d327fa76a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Wed, 24 Apr 2024 11:51:34 +0200 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=8F=8F=20incorporate=20review=20fee?= =?UTF-8?q?dback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/config.yml | 8 ++--- fdroidserver/deploy.py | 72 ++++++++++++++++++++++++++++-------------- tests/deploy.TestCase | 3 -- 3 files changed, 52 insertions(+), 31 deletions(-) diff --git a/examples/config.yml b/examples/config.yml index 3cfc17c5..45c9a2f7 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -212,19 +212,19 @@ # index_only: true -# These settings allows using `fdroid deploy` for publishing APK files from +# These settings allow using `fdroid deploy` for publishing APK files from # your repository to GitHub Releases. (You should also run `fdroid update` # every time before deploying to GitHub releases to update index files.) Here's # an example for this deployment automation: # https://github.com/f-droid/fdroidclient/releases/ # -# Currently versions which are assigned to a release channel (e.g. alpha or +# Currently, versions which are assigned to a release channel (e.g. alpha or # beta releases) are ignored. # -# In the examble below tokens are read from environment variables. Putting +# In the example below, tokens are read from environment variables. Putting # tokens directly into the config file is also supported but discouraged. It is # highly recommended to use a "Fine-grained personal access token", which is -# restriced to the minimum required permissions, which are: +# restricted to the minimum required permissions, which are: # * Metadata - read # * Contents - read/write # (https://github.com/settings/personal-access-tokens/new) diff --git a/fdroidserver/deploy.py b/fdroidserver/deploy.py index b8c0d711..4ca7dca9 100644 --- a/fdroidserver/deploy.py +++ b/fdroidserver/deploy.py @@ -1117,8 +1117,7 @@ def push_binary_transparency(git_repo_path, git_remote): def find_release_infos(index_v2_path, repo_dir, package_names): - """ - Find files, texts, etc. for uploading to a release page in index-v2.json. + """Find files, texts, etc. for uploading to a release page in index-v2.json. This function parses index-v2.json for file-paths elegible for deployment to release pages. (e.g. GitHub releases) It also groups these files by @@ -1137,19 +1136,16 @@ def find_release_infos(index_v2_path, repo_dir, package_names): for version in package.get('versions', {}).values(): if package_name not in release_infos: release_infos[package_name] = {} - ver_name = version['manifest']['versionName'] - apk_path = version['file']['name'] - if apk_path.startswith('/'): - apk_path = apk_path[1:] - apk_path = repo_dir / apk_path - files = [apk_path] - asc_path = pathlib.Path(str(apk_path) + '.asc') + version_name = version['manifest']['versionName'] + version_path = repo_dir / version['file']['name'].lstrip("/") + files = [version_path] + asc_path = pathlib.Path(str(version_path) + '.asc') if asc_path.is_file(): files.append(asc_path) - idsig_path = pathlib.Path(str(apk_path) + '.idsig') - if idsig_path.is_file(): - files.append(idsig_path) - release_infos[package_name][ver_name] = { + sig_path = pathlib.Path(str(version_path) + '.sig') + if sig_path.is_file(): + files.append(sig_path) + release_infos[package_name][version_name] = { 'files': files, 'whatsNew': version.get('whatsNew', {}).get("en-US"), 'hasReleaseChannels': len(version.get('releaseChannels', [])) > 0, @@ -1174,24 +1170,41 @@ def upload_to_github_releases(repo_section, gh_config, global_gh_token): for package_name in repo_conf.get('packages', []): package_names.append(package_name) - release_infos = fdroidserver.deploy.find_release_infos(index_v2_path, repo_dir, package_names) + release_infos = fdroidserver.deploy.find_release_infos( + index_v2_path, repo_dir, package_names + ) for repo_conf in gh_config: upload_to_github_releases_repo(repo_conf, release_infos, global_gh_token) def upload_to_github_releases_repo(repo_conf, release_infos, global_gh_token): - repo = repo_conf.get('repo') + repo = repo_conf.get("repo") if not repo: - logging.warning(_("One of the 'github_releases' config items is missing the 'repo' value. skipping ...")) + logging.warning( + _( + "One of the 'github_releases' config items is missing the " + "'repo' value. skipping ..." + ) + ) return - token = repo_conf.get('token') or global_gh_token + token = repo_conf.get("token") or global_gh_token if not token: - logging.warning(_("One of the 'github_releases' config itmes is missing the 'token' value. skipping ...")) + logging.warning( + _( + "One of the 'github_releases' config itmes is missing the " + "'token' value. skipping ..." + ) + ) return - packages = repo_conf.get('packages', []) + packages = repo_conf.get("packages", []) if not packages: - logging.warning(_("One of the 'github_releases' config itmes is missing the 'packages' value. skipping ...")) + logging.warning( + _( + "One of the 'github_releases' config itmes is missing the " + "'packages' value. skipping ..." + ) + ) return # lookup all versionNames (git tags) for all packages available in the @@ -1209,14 +1222,23 @@ def upload_to_github_releases_repo(repo_conf, release_infos, global_gh_token): # Making sure we're not uploading this version when releaseChannels # is set. (releaseChannels usually mean it's e.g. an alpha or beta # version) - if not release_infos.get(packages[0], {}).get(version, {}).get('hasReleaseChannels'): + if ( + not release_infos.get(packages[0], {}) + .get(version, {}) + .get('hasReleaseChannels') + ): # collect files associated with this github release files = [] for package in packages: - files.extend(release_infos.get(package, {}).get(version, {}).get('files', [])) + files.extend( + release_infos.get(package, {}).get(version, {}).get('files', []) + ) # always use the whatsNew text from the first app listed in # config.qml github_releases.packages - text = release_infos.get(packages[0], {}).get(version, {}).get('whatsNew') or '' + text = ( + release_infos.get(packages[0], {}).get(version, {}).get('whatsNew') + or '' + ) if 'release_notes_prepend' in repo_conf: text = repo_conf['release_notes_prepend'] + "\n\n" + text # create new release on github and upload all associated files @@ -1346,7 +1368,9 @@ def main(): if config.get('virustotal_apikey'): upload_to_virustotal(repo_section, config.get('virustotal_apikey')) if config.get('github_releases'): - upload_to_github_releases(repo_section, config.get('github_releases'), config.get('github_token')) + upload_to_github_releases( + repo_section, config.get('github_releases'), config.get('github_token') + ) binary_transparency_remote = config.get('binary_transparency_remote') if binary_transparency_remote: diff --git a/tests/deploy.TestCase b/tests/deploy.TestCase index 041f0eaf..2db73a02 100755 --- a/tests/deploy.TestCase +++ b/tests/deploy.TestCase @@ -1211,7 +1211,6 @@ class DeployTest(unittest.TestCase): class GitHubReleasesTest(unittest.TestCase): - def test_find_release_infos(self): self.maxDiff = None @@ -1331,7 +1330,6 @@ class GitHubReleasesTest(unittest.TestCase): ), unittest.mock.patch( "fdroidserver.deploy.upload_to_github_releases_repo", urr_mock ), tempfile.TemporaryDirectory() as tmpdir: - with open(Path(tmpdir) / "index-v2.json", "w") as f: f.write("") @@ -1370,7 +1368,6 @@ class GitHubReleasesTest(unittest.TestCase): class Test_UploadToGithubReleasesRepo(unittest.TestCase): - def setUp(self): self.repo_conf = { "repo": "example/app", From 3cf36852807392fe18c9645f8847b68f7dec959c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Mon, 29 Apr 2024 15:42:28 +0200 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=90=90=20appeace=20bandint=20securi?= =?UTF-8?q?ty=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In this particular case bandit was complaining about potential path escape exploits on urlopen. However the urls are safe enough, because all template parameters inserted into the url are from: * config.yml - malicious changes to config.yml are possible that's already a lot bigger issue than this than redirecting github api calls. * git tags witch are present in bot the local index-v2.json file (as versionName) and the remote github API. (git tags don't allow the string '..') --- fdroidserver/github.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/fdroidserver/github.py b/fdroidserver/github.py index 68396235..d72e9bb0 100644 --- a/fdroidserver/github.py +++ b/fdroidserver/github.py @@ -51,7 +51,7 @@ class GithubApi: """List of all tags that are associated with a release for this repo on GitHub.""" names = [] req = self._req(f"https://api.github.com/repos/{self._repo_path}/releases") - with urllib.request.urlopen(req) as resp: + with urllib.request.urlopen(req) as resp: # nosec CWE-22 disable bandit warning releases = json.load(resp) for release in releases: names.append(release['tag_name']) @@ -71,7 +71,7 @@ class GithubApi: req = self._req( f"https://api.github.com/repos/{self._repo_path}/git/matching-refs/tags/{tag}" ) - with urllib.request.urlopen(req) as resp: + with urllib.request.urlopen(req) as resp: # nosec CWE-22 disable bandit warning rd = json.load(resp) return len(rd) == 1 and rd[0].get("ref", False) == f"refs/tags/{tag}" return False @@ -82,7 +82,7 @@ class GithubApi: req = self._req( f"https://api.github.com/repos/{self._repo_path}/git/matching-refs/tags/" ) - with urllib.request.urlopen(req) as resp: + with urllib.request.urlopen(req) as resp: # nosec CWE-22 disable bandit warning refs = json.load(resp) for ref in refs: r = ref.get('ref', '') @@ -118,7 +118,9 @@ class GithubApi: ).encode("utf-8"), ) try: - with urllib.request.urlopen(req) as resp: + with urllib.request.urlopen( # nosec CWE-22 disable bandit warning + req + ) as resp: release_id = json.load(resp)['id'] except urllib.error.HTTPError as e: if e.status == 422: @@ -153,6 +155,6 @@ class GithubApi: }, data=f.read(), ) - with urllib.request.urlopen(req): + with urllib.request.urlopen(req): # nosec CWE-22 disable bandit warning return True return False From 0fa88c5c205d083f742d0e800b55ad0ba289c5d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Tue, 11 Jun 2024 12:24:36 +0200 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=92=87=20implement=20review=20nits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement review nits as requested bei @eighthave in https://gitlab.com/fdroid/fdroidserver/-/merge_requests/1471 --- examples/config.yml | 9 ++++----- fdroidserver/deploy.py | 33 +++++++++++++++++++-------------- fdroidserver/github.py | 5 ++++- tests/deploy.TestCase | 29 +++++++++++++++++------------ 4 files changed, 44 insertions(+), 32 deletions(-) diff --git a/examples/config.yml b/examples/config.yml index 45c9a2f7..f15c00da 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -231,16 +231,15 @@ # # github_token: {env: GITHUB_TOKEN} # github_releases: -# - repo: f-droid/fdroidclient -# packages: +# - projectUrl: https://github.com/f-droid/fdroidclient +# packageNames: # - org.fdroid.basic # - org.fdroid.fdroid # release_notes_prepend: | # Re-post of official F-Droid App release from https://f-droid.org -# - repo: example/app +# - projectUrl: https://github.com/example/app +# packageNames: com.example.app # token: {env: GITHUB_TOKEN_EXAMPLE} -# packages: -# - com.example.app # Most git hosting services have hard size limits for each git repo. diff --git a/fdroidserver/deploy.py b/fdroidserver/deploy.py index 4ca7dca9..8d2b5dca 100644 --- a/fdroidserver/deploy.py +++ b/fdroidserver/deploy.py @@ -1167,7 +1167,7 @@ def upload_to_github_releases(repo_section, gh_config, global_gh_token): package_names = [] for repo_conf in gh_config: - for package_name in repo_conf.get('packages', []): + for package_name in repo_conf.get('packageNames', []): package_names.append(package_name) release_infos = fdroidserver.deploy.find_release_infos( @@ -1179,12 +1179,12 @@ def upload_to_github_releases(repo_section, gh_config, global_gh_token): def upload_to_github_releases_repo(repo_conf, release_infos, global_gh_token): - repo = repo_conf.get("repo") - if not repo: + projectUrl = repo_conf.get("projectUrl") + if not projectUrl: logging.warning( _( "One of the 'github_releases' config items is missing the " - "'repo' value. skipping ..." + "'projectUrl' value. skipping ..." ) ) return @@ -1197,12 +1197,14 @@ def upload_to_github_releases_repo(repo_conf, release_infos, global_gh_token): ) ) return - packages = repo_conf.get("packages", []) - if not packages: + conf_package_names = repo_conf.get("packageNames", []) + if type(conf_package_names) == str: + conf_package_names = [conf_package_names] + if not conf_package_names: logging.warning( _( "One of the 'github_releases' config itmes is missing the " - "'packages' value. skipping ..." + "'packageNames' value. skipping ..." ) ) return @@ -1210,11 +1212,11 @@ def upload_to_github_releases_repo(repo_conf, release_infos, global_gh_token): # lookup all versionNames (git tags) for all packages available in the # local fdroid repo all_local_versions = set() - for package_name in repo_conf['packages']: + for package_name in conf_package_names: for version in release_infos.get(package_name, {}).keys(): all_local_versions.add(version) - gh = fdroidserver.github.GithubApi(token, repo) + gh = fdroidserver.github.GithubApi(token, projectUrl) unreleased_tags = gh.list_unreleased_tags() for version in all_local_versions: @@ -1223,20 +1225,22 @@ def upload_to_github_releases_repo(repo_conf, release_infos, global_gh_token): # is set. (releaseChannels usually mean it's e.g. an alpha or beta # version) if ( - not release_infos.get(packages[0], {}) + not release_infos.get(conf_package_names[0], {}) .get(version, {}) .get('hasReleaseChannels') ): # collect files associated with this github release files = [] - for package in packages: + for package in conf_package_names: files.extend( release_infos.get(package, {}).get(version, {}).get('files', []) ) # always use the whatsNew text from the first app listed in - # config.qml github_releases.packages + # config.yml github_releases.packageNames text = ( - release_infos.get(packages[0], {}).get(version, {}).get('whatsNew') + release_infos.get(conf_package_names[0], {}) + .get(version, {}) + .get('whatsNew') or '' ) if 'release_notes_prepend' in repo_conf: @@ -1330,7 +1334,8 @@ def main(): logging.warning( _('No option set! Edit your config.yml to set at least one of these:') + '\nserverwebroot, servergitmirrors, local_copy_dir, awsbucket, ' - + 'virustotal_apikey, androidobservatory, or binary_transparency_remote' + + 'virustotal_apikey, androidobservatory, github_releases ' + + 'or binary_transparency_remote' ) sys.exit(1) diff --git a/fdroidserver/github.py b/fdroidserver/github.py index d72e9bb0..c522f522 100644 --- a/fdroidserver/github.py +++ b/fdroidserver/github.py @@ -33,7 +33,10 @@ class GithubApi: def __init__(self, api_token, repo_path): self._api_token = api_token - self._repo_path = repo_path + if repo_path.startswith("https://github.com/"): + self._repo_path = repo_path[19:] + else: + self._repo_path = repo_path def _req(self, url, data=None): h = { diff --git a/tests/deploy.TestCase b/tests/deploy.TestCase index 2db73a02..3d45625d 100755 --- a/tests/deploy.TestCase +++ b/tests/deploy.TestCase @@ -1313,12 +1313,12 @@ class GitHubReleasesTest(unittest.TestCase): def test_upload_to_github_releases(self): gh_config = [ { - "repo": "example/app", - "packages": ["com.example.app", "another.app"], + "projectUrl": "https://github.com/example/app", + "packageNames": ["com.example.app", "another.app"], }, { - "repo": "custom/app", - "packages": ["more.custom.app"], + "projectUrl": "https://github.com/custom/app", + "packageNames": ["more.custom.app"], "token": "custom_token", }, ] @@ -1343,21 +1343,22 @@ class GitHubReleasesTest(unittest.TestCase): ["com.example.app", "another.app", "more.custom.app"], ) + self.maxDiff = None self.assertListEqual( urr_mock.call_args_list, [ unittest.mock.call( { - "repo": "example/app", - "packages": ["com.example.app", "another.app"], + "projectUrl": "https://github.com/example/app", + "packageNames": ["com.example.app", "another.app"], }, "fri_result", "fake_global_token", ), unittest.mock.call( { - "repo": "custom/app", - "packages": ["more.custom.app"], + "projectUrl": "https://github.com/custom/app", + "packageNames": ["more.custom.app"], "token": "custom_token", }, "fri_result", @@ -1370,8 +1371,8 @@ class GitHubReleasesTest(unittest.TestCase): class Test_UploadToGithubReleasesRepo(unittest.TestCase): def setUp(self): self.repo_conf = { - "repo": "example/app", - "packages": ["com.example.app", "com.example.altapp", "another.app"], + "projectUrl": "https://github.com/example/app", + "packageNames": ["com.example.app", "com.example.altapp", "another.app"], } self.release_infos = { "com.example.app": { @@ -1414,7 +1415,9 @@ class Test_UploadToGithubReleasesRepo(unittest.TestCase): "global_token", ) - self.api_constructor.assert_called_once_with("global_token", "example/app") + self.api_constructor.assert_called_once_with( + "global_token", "https://github.com/example/app" + ) self.assertListEqual( self.api.create_release.call_args_list, @@ -1441,7 +1444,9 @@ class Test_UploadToGithubReleasesRepo(unittest.TestCase): "global_token", ) - self.api_constructor.assert_called_once_with("local_token", "example/app") + self.api_constructor.assert_called_once_with( + "local_token", "https://github.com/example/app" + ) self.assertListEqual( self.api.create_release.call_args_list,