diff --git a/.github/main.workflow b/.github/main.workflow index 4f4447fdf0..4b076bb4c6 100644 --- a/.github/main.workflow +++ b/.github/main.workflow @@ -1,21 +1,32 @@ -workflow "Build & Release Manifest" { - on = "push" - resolves = ["manifest-build-and-tag"] +#workflow "Build & Release Manifest" { +# on = "push" +# resolves = ["manifest-build-and-tag"] +#} +# +#action "manifest-build-and-tag" { +# uses = "./tools/docker/github" +# runs = ["python", "tools/ci/manifest_build.py"] +# secrets = ["GITHUB_TOKEN"] +#} +# +#workflow "Build & Publish Documentation Website" { +# on = "push" +# resolves = ["website-build-and-publish"] +#} +# +#action "website-build-and-publish" { +# uses = "./tools/docker/documentation" +# runs = ["/bin/bash", "tools/ci/website_build.sh"] +# secrets = ["DEPLOY_TOKEN"] +#} + +workflow "Synchronize the Pull Request Preview" { + on = "pull_request" + resolves = "update-pr-preview" } -action "manifest-build-and-tag" { +action "update-pr-preview" { uses = "./tools/docker/github" - runs = ["python", "tools/ci/manifest_build.py"] + runs = ["python", "tools/ci/update_pr_preview.py", "https://api.github.com"] secrets = ["GITHUB_TOKEN"] } - -workflow "Build & Publish Documentation Website" { - on = "push" - resolves = ["website-build-and-publish"] -} - -action "website-build-and-publish" { - uses = "./tools/docker/documentation" - runs = ["/bin/bash", "tools/ci/website_build.sh"] - secrets = ["DEPLOY_TOKEN"] -} diff --git a/.taskcluster.yml b/.taskcluster.yml index a8e297242f..e069c1702b 100644 --- a/.taskcluster.yml +++ b/.taskcluster.yml @@ -6,7 +6,7 @@ tasks: event_str: {$json: {$eval: event}} in: $flattenDeep: - - $if: tasks_for == "github-push" + - $if: false then: $map: $flatten: @@ -100,7 +100,7 @@ tasks: --test-type=${chunk[0]} --this-chunk=${chunk[1]} --total-chunks=${chunk[2]}; - - $if: tasks_for == "github-pull-request" + - $if: false # PR tasks that run the tests in various configurations then: # Taskcluster responds to a number of events issued by the GitHub API diff --git a/docs/writing-tests/submission-process.md b/docs/writing-tests/submission-process.md index d6ddce5a76..2a0aa0c47b 100644 --- a/docs/writing-tests/submission-process.md +++ b/docs/writing-tests/submission-process.md @@ -38,6 +38,23 @@ Hop on to the [mailing list][public-test-infra] or [IRC][] no need to announce your review request, as soon as you make a Pull Request GitHub will inform interested parties. +## Previews + +The website [wpt-submissions.live](http://wpt-submissions.live) exists to help +contributors demonstrate their proposed changes to others. If your pull request +is open and has the GitHub label `pull-request-has-preview`, then it will be +available at `http://wpt-submissions.live/{{pull request ID}}`, where "pull +request ID" is the numeric identifier for the pull request. + +For example, a pull request at https://github.com/web-platform-tests/wpt/pull/3 +has a pull request ID `3`. Once that has been assigned the +`pull-request-has-preview` label, then its contents can be viewed at +http://wpt-submissions.live/3. + +If you are [a GitHub +collaborator](https://help.github.com/en/articles/permission-levels-for-a-user-account-repository) +on WPT, the label and the preview will be created automatically. + [repo]: https://github.com/web-platform-tests/wpt/ [github flow]: https://guides.github.com/introduction/flow/ [public-test-infra]: https://lists.w3.org/Archives/Public/public-test-infra/ diff --git a/empty b/empty new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/empty @@ -0,0 +1 @@ + diff --git a/empty2 b/empty2 new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/empty2 @@ -0,0 +1 @@ + diff --git a/tools/ci/tests/test_update_pr_preview.py b/tools/ci/tests/test_update_pr_preview.py new file mode 100644 index 0000000000..c760bf2bfc --- /dev/null +++ b/tools/ci/tests/test_update_pr_preview.py @@ -0,0 +1,288 @@ +import BaseHTTPServer +import json +import os +import subprocess +import tempfile +import threading + +subject = os.path.join( + os.path.dirname(os.path.abspath(__file__)), '..', 'update_pr_preview.py' +) +test_host = 'localhost' + + +class MockHandler(BaseHTTPServer.BaseHTTPRequestHandler, object): + def do_all(self): + request = (self.command, self.path) + self.server.requests.append(request) + status_code, body = self.server.responses.get(request, (200, '{}')) + self.send_response(status_code) + self.end_headers() + self.wfile.write(body) + + def do_DELETE(self): + return self.do_all() + + def do_GET(self): + return self.do_all() + + def do_PATCH(self): + return self.do_all() + + def do_POST(self): + return self.do_all() + + +class MockServer(BaseHTTPServer.HTTPServer, object): + '''HTTP server that responds to all requests with status code 200 and body + '{}' unless an alternative status code and body are specified for the given + method and path in the `responses` parameter.''' + def __init__(self, address, responses=None): + super(MockServer, self).__init__(address, MockHandler) + self.responses = responses or {} + self.requests = [] + + +def assert_success(returncode): + assert returncode == 0 + + +def assert_neutral(returncode): + assert returncode == 78 + + +def assert_fail(returncode): + assert returncode not in (0, 78) + + +def run(event_data, responses=None): + event_data_file = tempfile.mkstemp()[1] + env = { + 'GITHUB_EVENT_PATH': event_data_file, + 'GITHUB_REPOSITORY': 'test-org/test-repo' + } + env.update(os.environ) + server = MockServer((test_host, 0), responses) + test_port = server.server_address[1] + threading.Thread(target=lambda: server.serve_forever()).start() + + try: + with open(event_data_file, 'w') as handle: + json.dump(event_data, handle) + + child = subprocess.Popen( + ['python', subject, 'http://{}:{}'.format(test_host, test_port)], + env=env + ) + + child.communicate() + finally: + server.shutdown() + os.remove(event_data_file) + + return child.returncode, server.requests + + +def default_data(action): + return { + 'pull_request': { + 'number': 543, + 'closed_at': None, + 'head': { + 'sha': 'deadbeef' + }, + 'user': { + 'login': 'rms' + }, + 'labels': [ + {'name': 'foo'}, + {'name': 'bar'} + ] + }, + 'action': action + } + + + +def test_close_active_with_label(): + event_data = default_data('closed') + event_data['pull_request']['closed_at'] = '2019-07-05' + event_data['pull_request']['labels'].append( + {'name': 'pull-request-has-preview'} + ) + delete_label = ( + 'DELETE', + '/repos/test-org/test-repo/issues/543/labels/pull-request-has-preview' + ) + delete_tag = ( + 'DELETE', '/repos/test-org/test-repo/git/refs/tags/pr_preview_543' + ) + + returncode, requests = run(event_data) + + assert_success(returncode) + assert delete_label in requests + assert delete_tag in requests + + +def test_close_active_with_label_error(): + event_data = default_data('closed') + event_data['pull_request']['closed_at'] = '2019-07-05' + event_data['pull_request']['labels'].append( + {'name': 'pull-request-has-preview'} + ) + responses = {( + 'DELETE', + '/repos/test-org/test-repo/issues/543/labels/pull-request-has-preview' + ): (500, '{}')} + + returncode, requests = run(event_data, responses) + + assert_fail(returncode) + + +def test_close_active_without_label(): + event_data = default_data('closed') + event_data['pull_request']['closed_at'] = '2019-07-05' + + returncode, requests = run(event_data) + + assert_neutral(returncode) + assert len(requests) == 0 + + +def test_open_with_label(): + event_data = default_data('opened') + event_data['pull_request']['labels'].append( + {'name': 'pull-request-has-preview'} + ) + + returncode, requests = run(event_data) + + assert_success(returncode) + expected = ( + 'PATCH', + '/repos/test-org/test-repo/git/refs/tags/pr_preview_543' + ) + assert expected in requests + + +def test_open_without_label_for_collaborator(): + event_data = default_data('opened') + responses = { + ('GET', '/repos/test-org/test-repo/collaborators/rms'): (204, ''), + ('GET', '/repos/test-org/test-repo/git/refs/tags/pr_preview_543'): + (404, '{}') + } + + returncode, requests = run(event_data, responses) + + assert_success(returncode) + create_label = ('POST', '/repos/test-org/test-repo/issues/543/labels') + create_tag = ('POST', '/repos/test-org/test-repo/git/refs') + assert responses.keys()[0] in requests + assert responses.keys()[1] in requests + assert create_label in requests + assert create_tag in requests + + +def test_open_without_label_for_non_collaborator(): + event_data = default_data('opened') + responses = { + ('GET', '/repos/test-org/test-repo/collaborators/rms'): (404, '{}') + } + + returncode, requests = run(event_data, responses) + + assert_neutral(returncode) + expected = [( + 'GET', '/repos/test-org/test-repo/collaborators/rms' + )] + assert expected == requests + + +def test_add_unrelated_label(): + event_data = default_data('labeled') + event_data['label'] = {'name': 'foobar'} + event_data['pull_request']['labels'].append({'name': 'foobar'}) + + returncode, requests = run(event_data) + + assert_neutral(returncode) + assert len(requests) == 0 + + +def test_add_active_label(): + event_data = default_data('labeled') + event_data['label'] = {'name': 'pull-request-has-preview'} + event_data['pull_request']['labels'].append( + {'name': 'pull-request-has-preview'} + ) + responses = {( + 'GET', '/repos/test-org/test-repo/git/refs/tags/pr_preview_543' + ): (404, '{}')} + + returncode, requests = run(event_data, responses) + + assert_success(returncode) + expected = ('POST', '/repos/test-org/test-repo/git/refs') + assert responses.keys()[0] in requests + assert expected in requests + + +def test_remove_unrelated_label(): + event_data = default_data('unlabeled') + event_data['label'] = {'name': 'foobar'} + + returncode, requests = run(event_data) + + assert_neutral(returncode) + assert len(requests) == 0 + + +def test_remove_active_label(): + event_data = default_data('unlabeled') + event_data['label'] = {'name': 'pull-request-has-preview'} + responses = { + ('DELETE', '/repos/test-org/test-repo/git/refs/tags/pr_preview_543'): + (204, '') + } + + returncode, requests = run(event_data, responses) + + assert_success(returncode) + assert responses.keys()[0] in requests + + +def test_synchronize_without_label(): + event_data = default_data('synchronize') + + returncode, requests = run(event_data) + + assert_neutral(returncode) + assert len(requests) == 0 + + +def test_synchronize_with_label(): + event_data = default_data('synchronize') + event_data['pull_request']['labels'].append( + {'name': 'pull-request-has-preview'} + ) + + returncode, requests = run(event_data) + + assert_success(returncode) + expected = ( + 'PATCH', + '/repos/test-org/test-repo/git/refs/tags/pr_preview_543' + ) + assert expected in requests + + +def test_unrecognized_action(): + event_data = default_data('assigned') + + returncode, requests = run(event_data) + + assert_neutral(returncode) + assert len(requests) == 0 diff --git a/tools/ci/update_pr_preview.py b/tools/ci/update_pr_preview.py new file mode 100644 index 0000000000..fe22614963 --- /dev/null +++ b/tools/ci/update_pr_preview.py @@ -0,0 +1,247 @@ +# wpt-submissions.live is a public deployment of WPT, maintained in an external +# repository. It automatically fetches and deploys all tags in the WPT +# repository which match a certain pattern. This behavior is intended to be +# used for pull requests so that reviewers can preview changes without running +# the WPT server locally. +# +# This script facilitates the service by maintaining the git tags. It creates +# and updates tags in response to GitHub events. It does this automatically for +# pull requests from GitHub users who have "collaborator" access permissions to +# the WPT repository. It also does this for any pull requests which bear the +# `pull-request-has-preview` label. Collaborators can add or remove this label +# to enable or disable the preview for submissions from non-collaborators. +# +# Although the script relies on secret access tokens, it is *not* limited to +# use for pull requests from trusted collaborators due to the way GitHub +# Actions are executed: +# +# > # Pull request events for forked repositories +# > +# > [...] +# > +# > ## Pull request with base and head branches in different repositories +# > +# > The base repository receives a pull_request event where the SHA is the +# > latest commit of base branch and ref is the base branch. +# +# https://developer.github.com/actions/managing-workflows/workflow-configuration-options/#pull-request-events-for-forked-repositories +import json +import logging +import os +import sys + +import requests + +active_label = 'pull-request-has-preview' + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class Status(object): + SUCCESS = 0 + FAIL = 1 + NEUTRAL = 78 + + +def request(url, method_name, data=None, json_data=None, ignore_body=False): + github_token = os.environ.get('GITHUB_TOKEN') + + kwargs = { + 'headers': { + 'Authorization': 'token {}'.format(github_token), + 'Accept': 'application/vnd.github.machine-man-preview+json' + } + } + method = getattr(requests, method_name) + + logger.info('Issuing request: {} {}'.format(method_name.upper(), url)) + if json_data is not None or data is not None: + kwargs['json'] = json_data + kwargs['data'] = data + + resp = method(url, **kwargs) + + resp.raise_for_status() + + if not ignore_body: + return resp.json() + + +def resource_exists(url): + try: + request(url, 'get', ignore_body=True) + except requests.HTTPError as exception: + if exception.response.status_code == 404: + return False + raise + + return True + + +class GitHub(object): + def __init__(self, api_root, owner, repo): + self.api_root = api_root + self.owner = owner + self.repo = repo + + def is_collaborator(self, login): + return resource_exists( + '{}/repos/{}/{}/collaborators/{}'.format( + self.api_root, self.owner, self.repo, login + ) + ) + + def tag_exists(self, tag): + return resource_exists( + '{}/repos/{}/{}/git/refs/tags/{}'.format( + self.api_root, self.owner, self.repo, tag + ) + ) + + def create_tag(self, tag, sha): + data = { + 'ref': 'refs/tags/{}'.format(tag), + 'sha': sha + } + url = '{}/repos/{}/{}/git/refs'.format( + self.api_root, self.owner, self.repo + ) + + logger.info('Creating tag "{}" as {}'.format(tag, sha)) + + request(url, 'post', json_data=data) + + def update_tag(self, tag, sha): + data = { + 'force': True, + 'sha': sha + } + url = '{}/repos/{}/{}/git/refs/tags/{}'.format( + self.api_root, self.owner, self.repo, tag + ) + + logger.info('Updating tag "{}" as {}'.format(tag, sha)) + + request(url, 'patch', json_data=data) + + def delete_tag(self, tag): + url = '{}/repos/{}/{}/git/refs/tags/{}'.format( + self.api_root, self.owner, self.repo, tag + ) + + logger.info('Deleting tag "{}"'.format(tag)) + + try: + request(url, 'delete', ignore_body=True) + except requests.HTTPError as exception: + if exception.response.status_code != 404: + raise + + logger.info( + 'Attempted to delete non-existent tag: {}'.format(tag) + ) + + def tag(self, tag, sha): + if self.tag_exists(tag): + self.update_tag(tag, sha) + else: + self.create_tag(tag, sha) + + def add_label(self, pr_number, label_name): + data = { + 'labels': [label_name] + } + url = '{}/repos/{}/{}/issues/{}/labels'.format( + self.api_root, self.owner, self.repo, pr_number + ) + + logger.info('Adding label') + + request(url, 'post', json_data=data) + + def remove_label(self, pr_number, label_name): + url = '{}/repos/{}/{}/issues/{}/labels/{}'.format( + self.api_root, self.owner, self.repo, pr_number, label_name + ) + + logger.info('Removing label') + + try: + request(url, 'delete') + except requests.HTTPError as exception: + if exception.response.status_code != 404: + raise + + logger.info( + 'Attempted to remove non-existent label: {}'.format(label_name) + ) + + +def main(api_root): + with open(os.environ['GITHUB_EVENT_PATH']) as handle: + event = json.load(handle) + logger.info(json.dumps(event, indent=2)) + + if 'pull_request' not in event: + logger.info('Unexpected event data') + return Status.FAIL + + owner, repo = os.environ['GITHUB_REPOSITORY'].split('/', 1) + github = GitHub(api_root, owner, repo) + action = event['action'] + pr_number = event['pull_request']['number'] + tag_name = 'pr_preview_{}'.format(pr_number) + sha = event['pull_request']['head']['sha'] + is_open = event['pull_request']['closed_at'] is None + login = event['pull_request']['user']['login'] + has_label = any([ + label['name'] == active_label + for label in event['pull_request']['labels'] + ]) + target_label = event.get('label', {}).get('name') + + if not is_open: + if action == 'closed' and has_label: + github.remove_label(pr_number, active_label) + + # > An action can't trigger other workflows. For example, a push, + # > deployment, or any task performed within an action with the + # > provided `GITHUB_TOKEN` will not trigger a workflow listening + # > on push, deploy, or any other supported action triggers. + # + # https://developer.github.com/actions/managing-workflows/workflow-configuration-options/ + github.delete_tag(tag_name) + + return Status.SUCCESS + + return Status.NEUTRAL + + if action in ('opened', 'reopened') and has_label: + github.tag(tag_name, sha) + elif action in ('opened', 'reopened') and github.is_collaborator(login): + github.add_label(pr_number, active_label) + + # > An action can't trigger other workflows. For example, a push, + # > deployment, or any task performed within an action with the + # > provided `GITHUB_TOKEN` will not trigger a workflow listening on + # > push, deploy, or any other supported action triggers. + # + # https://developer.github.com/actions/managing-workflows/workflow-configuration-options/ + github.tag(tag_name, sha) + elif action == 'labeled' and target_label == active_label: + github.tag(tag_name, sha) + elif action == 'unlabeled' and target_label == active_label: + github.delete_tag(tag_name) + elif action == 'synchronize' and has_label: + github.tag(tag_name, sha) + else: + return Status.NEUTRAL + + return Status.SUCCESS + + +if __name__ == '__main__': + code = main(sys.argv[1]) + assert isinstance(code, int) + sys.exit(code) diff --git a/tools/tox.ini b/tools/tox.ini index ba6dc038a1..aba4f3faaa 100644 --- a/tools/tox.ini +++ b/tools/tox.ini @@ -8,6 +8,8 @@ deps = pytest-cov mock hypothesis + # `requests` is required by `update_pr_preview.py` + requests commands = pytest {posargs}