Skip to content

Commit

Permalink
fix(github): fix Github Actions support (#227)
Browse files Browse the repository at this point in the history
Fixup Github Actions support. Additionally adds a cli option to finish parallel builds.
  • Loading branch information
TimoRoth authored Jul 7, 2020
1 parent b120650 commit f597109
Show file tree
Hide file tree
Showing 9 changed files with 212 additions and 42 deletions.
102 changes: 73 additions & 29 deletions coveralls/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,12 @@ def __init__(self, token_required=True, service_name=None, **kwargs):

self.load_config_from_environment()

name, job, pr = self.load_config_from_ci_environment()
name, job, number, pr = self.load_config_from_ci_environment()
self.config['service_name'] = self.config.get('service_name', name)
if job:
# N.B. Github Actions uses a different chunk of the Coveralls
# config when running parallel builds, ie. `service_number` instead
# of `service_job_id`.
if name.startswith('github'):
self.config['service_number'] = job
else:
self.config['service_job_id'] = job
self.config['service_job_id'] = job
if number:
self.config['service_number'] = number
if pr:
self.config['service_pull_request'] = pr

Expand All @@ -76,67 +72,78 @@ def ensure_token(self):
@staticmethod
def load_config_from_appveyor():
pr = os.environ.get('APPVEYOR_PULL_REQUEST_NUMBER')
return 'appveyor', os.environ.get('APPVEYOR_BUILD_ID'), pr
return 'appveyor', os.environ.get('APPVEYOR_BUILD_ID'), None, pr

@staticmethod
def load_config_from_buildkite():
pr = os.environ.get('BUILDKITE_PULL_REQUEST')
if pr == 'false':
pr = None
return 'buildkite', os.environ.get('BUILDKITE_JOB_ID'), pr
return 'buildkite', os.environ.get('BUILDKITE_JOB_ID'), None, pr

@staticmethod
def load_config_from_circle():
pr = os.environ.get('CI_PULL_REQUEST', '').split('/')[-1] or None
return 'circle-ci', os.environ.get('CIRCLE_BUILD_NUM'), pr
return 'circle-ci', os.environ.get('CIRCLE_BUILD_NUM'), None, pr

@staticmethod
def load_config_from_github():
service_number = os.environ.get('GITHUB_SHA')
def load_config_from_github(self):
service = 'github'
if self.config.get('repo_token'):
service = 'github-actions'
else:
gh_token = os.environ.get('GITHUB_TOKEN')
if not gh_token:
raise CoverallsException(
'Running on Github Actions but GITHUB_TOKEN is not set. '
'Add "env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}" to '
'your step config.')
self.config['repo_token'] = gh_token

number = os.environ.get('GITHUB_RUN_ID')
pr = None
if os.environ.get('GITHUB_REF', '').startswith('refs/pull/'):
pr = os.environ.get('GITHUB_REF', '//').split('/')[2]
service_number += '-PR-{}'.format(pr)
return 'github-actions', service_number, pr
return service, None, number, pr

@staticmethod
def load_config_from_jenkins():
pr = os.environ.get('CI_PULL_REQUEST', '').split('/')[-1] or None
return 'jenkins', os.environ.get('BUILD_NUMBER'), pr
return 'jenkins', os.environ.get('BUILD_NUMBER'), None, pr

@staticmethod
def load_config_from_travis():
pr = os.environ.get('TRAVIS_PULL_REQUEST')
return 'travis-ci', os.environ.get('TRAVIS_JOB_ID'), pr
return 'travis-ci', os.environ.get('TRAVIS_JOB_ID'), None, pr

@staticmethod
def load_config_from_semaphore():
job = os.environ.get('SEMAPHORE_BUILD_NUMBER')
pr = os.environ.get('PULL_REQUEST_NUMBER')
return 'semaphore-ci', os.environ.get('SEMAPHORE_BUILD_NUMBER'), pr
return 'semaphore-ci', job, None, pr

@staticmethod
def load_config_from_unknown():
return 'coveralls-python', None, None
return 'coveralls-python', None, None, None

def load_config_from_ci_environment(self):
if os.environ.get('APPVEYOR'):
name, job, pr = self.load_config_from_appveyor()
name, job, number, pr = self.load_config_from_appveyor()
elif os.environ.get('BUILDKITE'):
name, job, pr = self.load_config_from_buildkite()
name, job, number, pr = self.load_config_from_buildkite()
elif os.environ.get('CIRCLECI'):
name, job, pr = self.load_config_from_circle()
name, job, number, pr = self.load_config_from_circle()
elif os.environ.get('GITHUB_ACTIONS'):
name, job, pr = self.load_config_from_github()
name, job, number, pr = self.load_config_from_github()
elif os.environ.get('JENKINS_HOME'):
name, job, pr = self.load_config_from_jenkins()
name, job, number, pr = self.load_config_from_jenkins()
elif os.environ.get('TRAVIS'):
self._token_required = False
name, job, pr = self.load_config_from_travis()
name, job, number, pr = self.load_config_from_travis()
elif os.environ.get('SEMAPHORE'):
name, job, pr = self.load_config_from_semaphore()
name, job, number, pr = self.load_config_from_semaphore()
else:
name, job, pr = self.load_config_from_unknown()
return (name, job, pr)
name, job, number, pr = self.load_config_from_unknown()
return (name, job, number, pr)

def load_config_from_environment(self):
coveralls_host = os.environ.get('COVERALLS_HOST')
Expand All @@ -159,6 +166,10 @@ def load_config_from_environment(self):
if flag_name:
self.config['flag_name'] = flag_name

number = os.environ.get('COVERALLS_SERVICE_JOB_NUMBER')
if number:
self.config['service_number'] = number

def load_config_from_file(self):
try:
with open(os.path.join(os.getcwd(),
Expand Down Expand Up @@ -196,6 +207,39 @@ def wear(self, dry_run=False):
except Exception as e:
raise CoverallsException('Could not submit coverage: {}'.format(e))

def parallel_finish(self):
payload = {
'payload': {
'status': 'done'
}
}
if self.config.get('repo_token'):
payload['repo_token'] = self.config['repo_token']
if self.config.get('service_number'):
payload['payload']['build_num'] = self.config['service_number']

# Service-Specific Parameters
if os.environ.get('GITHUB_REPOSITORY'):
payload['repo_name'] = os.environ.get('GITHUB_REPOSITORY')

endpoint = '{}/webhook'.format(self._coveralls_host.rstrip('/'))
verify = not bool(os.environ.get('COVERALLS_SKIP_SSL_VERIFY'))
response = requests.post(endpoint, json=payload, verify=verify)
try:
response.raise_for_status()
response = response.json()
except Exception as e:
raise CoverallsException('Parallel finish failed: {}'.format(e))

if 'error' in response:
e = response['error']
raise CoverallsException('Parallel finish failed: {}'.format(e))

if 'done' not in response or not response['done']:
raise CoverallsException('Parallel finish failed')

return response

def create_report(self):
"""Generate json dumped report for coveralls api."""
data = self.create_data()
Expand Down
7 changes: 7 additions & 0 deletions coveralls/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
--rcfile=<file> Specify configuration file. [default: .coveragerc]
--output=<file> Write report to file. Doesn't send anything.
--merge=<file> Merge report from file when submitting.
--finish Finish parallel jobs.
-h --help Display this help.
-v --verbose Print extra info, always enabled when debugging.
Expand Down Expand Up @@ -74,6 +75,12 @@ def main(argv=None):
coverallz.save_report(options['--output'])
return

if options['--finish']:
log.info('Finishing parallel jobs...')
coverallz.parallel_finish()
log.info('Done')
return

log.info('Submitting coverage to coveralls.io...')
result = coverallz.wear()

Expand Down
8 changes: 7 additions & 1 deletion coveralls/exception.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
class CoverallsException(Exception):
pass
def __eq__(self, other):
if isinstance(other, self.__class__):
return str(self) == str(other)
return False

def __ne__(self, other):
return not self.__eq__(other)
42 changes: 36 additions & 6 deletions docs/usage/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,43 @@ Sample ``.coveralls.yml`` file::
Github Actions Gotcha
---------------------

There's something weird with using Github Actions that we've not yet been able to entirely sort out -- if you find you're getting a 422 error on Github Actions which looks like this::

Could not submit coverage: 422 Client Error: Unprocessable Entity for url: https://coveralls.io/api/v1/jobs

Then you may be able to solve it by ensuring your ``secret`` is named ``COVERALLS_REPO_TOKEN``; it seems like Github Actions may do Magic(tm) to some environment variables based on their name. The following config block seems to work properly::
Coveralls natively supports jobs running on Github Actions. You can directly pass the default-provided secret GITHUB_TOKEN::

env:
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
coveralls

For parallel builds you have to add a final step to let coveralls know the parallel build is finished. You also have to set COVERALLS_FLAG_NAME to something unique to the specific step, so re-runs of the same job don't keep piling up builds::

jobs:
test:
strategy:
matrix:
test-name:
- test1
- test2
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Test
run: ./run_tests.sh ${{ matrix.test-name }}
- name: Upload Coverage
run: coveralls
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COVERALLS_FLAG_NAME: ${{ matrix.test-name }}
COVERALLS_PARALLEL: true
coveralls:
name: Finish Coveralls
needs: test
runs-on: ubuntu-latest
container: python:3-slim
steps:
- name: Finished
run: |
pip3 install --upgrade coveralls
coveralls --finish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3 changes: 3 additions & 0 deletions docs/usage/tox.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ All variables:
- ``GITHUB_REF``
- ``GITHUB_SHA``
- ``GITHUB_HEAD_REF``
- ``GITHUB_REPOSITORY``
- ``GITHUB_RUN_ID``
- ``GITHUB_TOKEN``

Jenkins
-------
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
'docopt>=0.6.1',
'requests>=1.0.0',
],
tests_require=['mock', 'pytest'],
tests_require=['mock', 'responses', 'pytest'],
extras_require={
'yaml': ['PyYAML>=3.10'],
},
Expand Down
26 changes: 21 additions & 5 deletions tests/api/configuration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ def test_misconfigured(self):
'Not on TravisCI. You have to provide either repo_token in '
'.coveralls.mock or set the COVERALLS_REPO_TOKEN env var.')

@mock.patch.dict(os.environ, {'GITHUB_ACTIONS': 'true'}, clear=True)
def test_misconfigured_github(self):
with pytest.raises(Exception) as excinfo:
Coveralls()

assert str(excinfo.value).startswith(
'Running on Github Actions but GITHUB_TOKEN is not set.')

@mock.patch.dict(os.environ, {'APPVEYOR': 'True',
'APPVEYOR_BUILD_ID': '1234567',
'APPVEYOR_PULL_REQUEST_NUMBER': '1234'},
Expand Down Expand Up @@ -113,26 +121,34 @@ def test_circleci_no_config(self):
{'GITHUB_ACTIONS': 'true',
'GITHUB_REF': 'refs/pull/1234/merge',
'GITHUB_SHA': 'bb0e00166b28f49db04d6a8b8cb4bddb5afa529f',
'GITHUB_HEAD_REF': 'fixup-branch'},
'GITHUB_RUN_ID': '123456789',
'GITHUB_RUN_NUMBER': '12',
'GITHUB_HEAD_REF': 'fixup-branch',
'COVERALLS_REPO_TOKEN': 'xxx'},
clear=True)
def test_github_no_config(self):
cover = Coveralls(repo_token='xxx')
cover = Coveralls()
assert cover.config['service_name'] == 'github-actions'
assert cover.config['service_pull_request'] == '1234'
assert cover.config['service_number'] == '123456789'
assert 'service_job_id' not in cover.config

@mock.patch.dict(
os.environ,
{'GITHUB_ACTIONS': 'true',
'GITHUB_TOKEN': 'xxx',
'GITHUB_REF': 'refs/heads/master',
'GITHUB_SHA': 'bb0e00166b28f49db04d6a8b8cb4bddb5afa529f',
'GITHUB_RUN_ID': '987654321',
'GITHUB_RUN_NUMBER': '21',
'GITHUB_HEAD_REF': ''},
clear=True)
def test_github_no_config_no_pr(self):
cover = Coveralls(repo_token='xxx')
assert cover.config['service_name'] == 'github-actions'
assert 'service_pull_request' not in cover.config
cover = Coveralls()
assert cover.config['service_name'] == 'github'
assert cover.config['service_number'] == '987654321'
assert 'service_job_id' not in cover.config
assert 'service_pull_request' not in cover.config

@mock.patch.dict(
os.environ,
Expand Down
Loading

0 comments on commit f597109

Please sign in to comment.