mirror of
https://gitlab.com/fdroid/fdroidserver.git
synced 2024-11-13 02:30:11 +01:00
Merge branch 'gh-releases' into 'master'
🛰️ deploy: github releases See merge request fdroid/fdroidserver!1471
This commit is contained in:
commit
4a362541bd
@ -211,6 +211,37 @@
|
|||||||
# - url: https://gitlab.com/user/repo
|
# - url: https://gitlab.com/user/repo
|
||||||
# index_only: true
|
# index_only: true
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# beta releases) are ignored.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
# restricted to the minimum required permissions, which are:
|
||||||
|
# * Metadata - read
|
||||||
|
# * Contents - read/write
|
||||||
|
# (https://github.com/settings/personal-access-tokens/new)
|
||||||
|
#
|
||||||
|
# github_token: {env: GITHUB_TOKEN}
|
||||||
|
# github_releases:
|
||||||
|
# - 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
|
||||||
|
# - projectUrl: https://github.com/example/app
|
||||||
|
# packageNames: com.example.app
|
||||||
|
# token: {env: GITHUB_TOKEN_EXAMPLE}
|
||||||
|
|
||||||
|
|
||||||
# Most git hosting services have hard size limits for each git repo.
|
# Most git hosting services have hard size limits for each git repo.
|
||||||
# `fdroid deploy` will delete the git history when the git mirror 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
|
# approaches this limit to ensure that the repo will still fit when
|
||||||
|
@ -31,9 +31,10 @@ import yaml
|
|||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
import logging
|
import logging
|
||||||
from shlex import split
|
from shlex import split
|
||||||
|
import pathlib
|
||||||
import shutil
|
import shutil
|
||||||
import git
|
import git
|
||||||
from pathlib import Path
|
import fdroidserver.github
|
||||||
|
|
||||||
from . import _
|
from . import _
|
||||||
from . import common
|
from . import common
|
||||||
@ -663,7 +664,7 @@ def update_servergitmirrors(servergitmirrors, repo_section):
|
|||||||
return
|
return
|
||||||
|
|
||||||
options = common.get_options()
|
options = common.get_options()
|
||||||
workspace_dir = Path(os.getcwd())
|
workspace_dir = pathlib.Path(os.getcwd())
|
||||||
|
|
||||||
# right now we support only 'repo' git-mirroring
|
# right now we support only 'repo' git-mirroring
|
||||||
if repo_section == 'repo':
|
if repo_section == 'repo':
|
||||||
@ -1115,6 +1116,139 @@ def push_binary_transparency(git_repo_path, git_remote):
|
|||||||
raise FDroidException(_("Pushing to remote server failed!"))
|
raise FDroidException(_("Pushing to remote server failed!"))
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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_infos()['org.fdroid.fdroid']['0.19.2']
|
||||||
|
|
||||||
|
All paths in the returned data-structure are of type pathlib.Path.
|
||||||
|
"""
|
||||||
|
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_infos:
|
||||||
|
release_infos[package_name] = {}
|
||||||
|
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)
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
return release_infos
|
||||||
|
|
||||||
|
|
||||||
|
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.warning(
|
||||||
|
_(
|
||||||
|
"Error deploying 'github_releases', {} not present. (You might "
|
||||||
|
"need to run `fdroid update` first.)"
|
||||||
|
).format(index_v2_path)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
package_names = []
|
||||||
|
for repo_conf in gh_config:
|
||||||
|
for package_name in repo_conf.get('packageNames', []):
|
||||||
|
package_names.append(package_name)
|
||||||
|
|
||||||
|
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):
|
||||||
|
projectUrl = repo_conf.get("projectUrl")
|
||||||
|
if not projectUrl:
|
||||||
|
logging.warning(
|
||||||
|
_(
|
||||||
|
"One of the 'github_releases' config items is missing the "
|
||||||
|
"'projectUrl' value. skipping ..."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
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
|
||||||
|
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 "
|
||||||
|
"'packageNames' 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 conf_package_names:
|
||||||
|
for version in release_infos.get(package_name, {}).keys():
|
||||||
|
all_local_versions.add(version)
|
||||||
|
|
||||||
|
gh = fdroidserver.github.GithubApi(token, projectUrl)
|
||||||
|
unreleased_tags = gh.list_unreleased_tags()
|
||||||
|
|
||||||
|
for version in all_local_versions:
|
||||||
|
if version in unreleased_tags:
|
||||||
|
# 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(conf_package_names[0], {})
|
||||||
|
.get(version, {})
|
||||||
|
.get('hasReleaseChannels')
|
||||||
|
):
|
||||||
|
# collect files associated with this github release
|
||||||
|
files = []
|
||||||
|
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.yml github_releases.packageNames
|
||||||
|
text = (
|
||||||
|
release_infos.get(conf_package_names[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():
|
def main():
|
||||||
global config
|
global config
|
||||||
|
|
||||||
@ -1194,12 +1328,14 @@ def main():
|
|||||||
and not config.get('androidobservatory')
|
and not config.get('androidobservatory')
|
||||||
and not config.get('binary_transparency_remote')
|
and not config.get('binary_transparency_remote')
|
||||||
and not config.get('virustotal_apikey')
|
and not config.get('virustotal_apikey')
|
||||||
|
and not config.get('github_releases')
|
||||||
and local_copy_dir is None
|
and local_copy_dir is None
|
||||||
):
|
):
|
||||||
logging.warning(
|
logging.warning(
|
||||||
_('No option set! Edit your config.yml to set at least one of these:')
|
_('No option set! Edit your config.yml to set at least one of these:')
|
||||||
+ '\nserverwebroot, servergitmirrors, local_copy_dir, awsbucket, '
|
+ '\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)
|
sys.exit(1)
|
||||||
|
|
||||||
@ -1236,6 +1372,10 @@ def main():
|
|||||||
upload_to_android_observatory(repo_section)
|
upload_to_android_observatory(repo_section)
|
||||||
if config.get('virustotal_apikey'):
|
if config.get('virustotal_apikey'):
|
||||||
upload_to_virustotal(repo_section, 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')
|
||||||
|
)
|
||||||
|
|
||||||
binary_transparency_remote = config.get('binary_transparency_remote')
|
binary_transparency_remote = config.get('binary_transparency_remote')
|
||||||
if binary_transparency_remote:
|
if binary_transparency_remote:
|
||||||
|
163
fdroidserver/github.py
Normal file
163
fdroidserver/github.py
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
#!/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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
|
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 = {
|
||||||
|
"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: # nosec CWE-22 disable bandit warning
|
||||||
|
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: # 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
|
||||||
|
|
||||||
|
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: # nosec CWE-22 disable bandit warning
|
||||||
|
refs = json.load(resp)
|
||||||
|
for ref in refs:
|
||||||
|
r = ref.get('ref', '')
|
||||||
|
if r.startswith('refs/tags/'):
|
||||||
|
tags.append(r[10:])
|
||||||
|
return tags
|
||||||
|
|
||||||
|
def create_release(self, tag, files, body=''):
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
"body": body,
|
||||||
|
}
|
||||||
|
).encode("utf-8"),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
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:
|
||||||
|
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): # nosec CWE-22 disable bandit warning
|
||||||
|
return True
|
||||||
|
return False
|
@ -1210,6 +1210,261 @@ 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 = [
|
||||||
|
{
|
||||||
|
"projectUrl": "https://github.com/example/app",
|
||||||
|
"packageNames": ["com.example.app", "another.app"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"projectUrl": "https://github.com/custom/app",
|
||||||
|
"packageNames": ["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.maxDiff = None
|
||||||
|
self.assertListEqual(
|
||||||
|
urr_mock.call_args_list,
|
||||||
|
[
|
||||||
|
unittest.mock.call(
|
||||||
|
{
|
||||||
|
"projectUrl": "https://github.com/example/app",
|
||||||
|
"packageNames": ["com.example.app", "another.app"],
|
||||||
|
},
|
||||||
|
"fri_result",
|
||||||
|
"fake_global_token",
|
||||||
|
),
|
||||||
|
unittest.mock.call(
|
||||||
|
{
|
||||||
|
"projectUrl": "https://github.com/custom/app",
|
||||||
|
"packageNames": ["more.custom.app"],
|
||||||
|
"token": "custom_token",
|
||||||
|
},
|
||||||
|
"fri_result",
|
||||||
|
"fake_global_token",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Test_UploadToGithubReleasesRepo(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.repo_conf = {
|
||||||
|
"projectUrl": "https://github.com/example/app",
|
||||||
|
"packageNames": ["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", "https://github.com/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", "https://github.com/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__":
|
if __name__ == "__main__":
|
||||||
os.chdir(os.path.dirname(__file__))
|
os.chdir(os.path.dirname(__file__))
|
||||||
|
|
||||||
@ -1227,4 +1482,6 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
newSuite = unittest.TestSuite()
|
newSuite = unittest.TestSuite()
|
||||||
newSuite.addTest(unittest.makeSuite(DeployTest))
|
newSuite.addTest(unittest.makeSuite(DeployTest))
|
||||||
|
newSuite.addTest(unittest.makeSuite(GitHubReleasesTest))
|
||||||
|
newSuite.addTest(unittest.makeSuite(Test_UploadToGithubReleasesRepo))
|
||||||
unittest.main(failfast=False)
|
unittest.main(failfast=False)
|
||||||
|
164
tests/github.TestCase
Executable file
164
tests/github.TestCase
Executable file
@ -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'], body="bdy")
|
||||||
|
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", "body": "bdy"}')
|
||||||
|
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)
|
@ -19,9 +19,9 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
import unittest.mock
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
|
|
||||||
class TmpCwd:
|
class TmpCwd:
|
||||||
@ -84,5 +84,13 @@ def parse_args_for_test(parser, args):
|
|||||||
for arg in args:
|
for arg in args:
|
||||||
if arg[0] == '-':
|
if arg[0] == '-':
|
||||||
flags.append(flags)
|
flags.append(flags)
|
||||||
with mock.patch('sys.argv', flags):
|
with unittest.mock.patch('sys.argv', flags):
|
||||||
parse_args(parser)
|
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)
|
||||||
|
Loading…
Reference in New Issue
Block a user