diff --git a/shipitscript/src/shipitscript/data/create_new_nightly_release_task_schema.json b/shipitscript/src/shipitscript/data/create_new_nightly_release_task_schema.json new file mode 100644 index 000000000..820f1ca52 --- /dev/null +++ b/shipitscript/src/shipitscript/data/create_new_nightly_release_task_schema.json @@ -0,0 +1,51 @@ +{ + "title": "Taskcluster ShipIt create new nightly release task minimal schema", + "type": "object", + "properties": { + "dependencies": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "scopes": { + "type": "array", + "minItems": 2, + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "payload": { + "type": "object", + "properties": { + "product": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "buildid": { + "type": "string" + }, + "version": { + "type": "string" + }, + "locales": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string" + } + } + }, + "required": ["product", "channel", "buildid", "version", "locales"], + "additionalProperties": false + } + }, + "required": ["dependencies", "scopes", "payload"] +} + diff --git a/shipitscript/src/shipitscript/script.py b/shipitscript/src/shipitscript/script.py index 8c57e46a2..a7eba2dd6 100644 --- a/shipitscript/src/shipitscript/script.py +++ b/shipitscript/src/shipitscript/script.py @@ -114,12 +114,29 @@ def update_product_channel_version_action(context): ship_actions.update_product_channel_version(shipit_config, product, channel, version) +def create_new_nightly_release_action(context): + payload = context.task["payload"] + shipit_config = context.ship_it_instance_config + product = payload["product"] + channel = payload["channel"] + version = payload["version"] + buildid = payload["buildid"] + locales = payload["locales"] + nightly = ship_actions.get_nightly_metadata(shipit_config, product, channel, buildid) + if nightly and (nightly["version"] != version or nightly["locales"] != locales): + log.error("Nightly already exists, but version and/or locales don't match!") + sys.exit(1) + else: + ship_actions.create_new_nightly_release(shipit_config, product, channel, buildid, version, locales) + + # ACTION_MAP {{{1 ACTION_MAP = { "mark-as-shipped": mark_as_shipped_action, "mark-as-merged": mark_as_merged_action, "create-new-release": create_new_release_action, "update-product-channel-version": update_product_channel_version_action, + "create-new-nightly-release": create_new_nightly_release_action, } @@ -134,6 +151,7 @@ def get_default_config(): "mark_as_shipped_schema_file": os.path.join(data_dir, "mark_as_shipped_task_schema.json"), "mark_as_merged_schema_file": os.path.join(data_dir, "mark_as_merged_task_schema.json"), "create_new_release_schema_file": os.path.join(data_dir, "create_new_release_task_schema.json"), + "create_new_nightly_release_schema_file": os.path.join(data_dir, "create_new_nightly_release_task_schema.json"), } diff --git a/shipitscript/src/shipitscript/ship_actions.py b/shipitscript/src/shipitscript/ship_actions.py index 56645c5df..5f149ffd7 100644 --- a/shipitscript/src/shipitscript/ship_actions.py +++ b/shipitscript/src/shipitscript/ship_actions.py @@ -123,3 +123,16 @@ def update_product_channel_version(shipit_config, product, channel, version): log.info(f"Updating the current version of {product} {channel} to {version}...") response = release_api.update_product_channel_version(product, channel, version, headers=headers) log.info(response["message"]) + + +def get_nightly_metadata(shipit_config, product, channel, buildid): + release_api, headers = get_shipit_api_instance(shipit_config) + log.info(f"Getting metadata for nightly: {product}, {channel}, {buildid}") + return release_api.get_nightly_metadata(product, channel, buildid, headers=headers) + + +def create_new_nightly_release(shipit_config, product, channel, buildid, version, locales): + release_api, headers = get_shipit_api_instance(shipit_config) + log.info(f"Creating new nightly release: {product}, {channel}, {buildid}") + release_api.create_new_nightly_release(product, channel, buildid, version, locales, headers=headers) + log.info("New nightly release successfully created") diff --git a/shipitscript/src/shipitscript/shipitapi.py b/shipitscript/src/shipitscript/shipitapi.py index f84acc5c8..28728d0a6 100644 --- a/shipitscript/src/shipitscript/shipitapi.py +++ b/shipitscript/src/shipitscript/shipitapi.py @@ -189,3 +189,32 @@ def complete_merge_automation(self, automation_id, headers={}): """ resp = self._request(api_endpoint=f"/merge-automation/{automation_id}", method="PATCH", data="", headers=headers).content return resp + + def get_nightly_metadata(self, product, channel, buildid, headers=None): + headers = headers if headers else {} + params = {"product": product, "channel": channel, "buildid": buildid} + + try: + response = self._request(api_endpoint=f"/nightly-release?{urllib.parse.urlencode(params)}", method="GET", headers=headers) + return response.json() + except Exception: + log.error(f"Caught error while getting metadata for {product}, {channel}, {buildid}!", exc_info=True) + raise + + def create_new_nightly_release(self, product, channel, buildid, version, locales, headers=None): + headers = headers if headers else {} + data = json.dumps( + { + "product": product, + "channel": channel, + "buildid": buildid, + "version": version, + "locales": locales, + } + ) + try: + response = self._request(api_endpoint="/nightly-release", method="POST", data=data, headers=headers) + return response.json() + except Exception: + log.error(f"Caught error while creating new nightly release: {product}, {channel}, {buildid}", exc_info=True) + raise diff --git a/shipitscript/src/shipitscript/task.py b/shipitscript/src/shipitscript/task.py index 7a7ff9999..a0f3cb9c2 100644 --- a/shipitscript/src/shipitscript/task.py +++ b/shipitscript/src/shipitscript/task.py @@ -12,6 +12,7 @@ "mark-as-merged": "mark_as_merged_schema_file", "create-new-release": "create_new_release_schema_file", "update-product-channel-version": "update_product_channel_version_schema_file", + "create-new-nightly-release": "create_new_nightly_release_schema_file", } diff --git a/shipitscript/tests/test_script.py b/shipitscript/tests/test_script.py index 2eca00ed8..89726ae66 100644 --- a/shipitscript/tests/test_script.py +++ b/shipitscript/tests/test_script.py @@ -94,6 +94,49 @@ async def test_async_main(context, monkeypatch, task, raises): await script.async_main(context) +@pytest.mark.parametrize( + "existing_nightly,expect_create,expect_exit", + ( + # no existing nightly: create one + (None, True, False), + # existing nightly matches version + locales: still calls create + ({"version": "150.0a1", "locales": ["en-US", "de"]}, True, False), + # existing nightly mismatches version: sys.exit(1) + ({"version": "149.0a1", "locales": ["en-US", "de"]}, False, True), + # existing nightly mismatches locales: sys.exit(1) + ({"version": "150.0a1", "locales": ["en-US"]}, False, True), + ), +) +def test_create_new_nightly_release_action(context, monkeypatch, existing_nightly, expect_create, expect_exit): + context.ship_it_instance_config = context.config["shipit_instance"] + context.task["payload"] = { + "product": "firefox", + "channel": "nightly", + "buildid": "20260525000000", + "version": "150.0a1", + "locales": ["en-US", "de"], + } + + get_nightly_metadata_mock = MagicMock(return_value=existing_nightly) + create_new_nightly_release_mock = MagicMock() + monkeypatch.setattr(ship_actions, "get_nightly_metadata", get_nightly_metadata_mock) + monkeypatch.setattr(ship_actions, "create_new_nightly_release", create_new_nightly_release_mock) + + if expect_exit: + with pytest.raises(SystemExit): + script.create_new_nightly_release_action(context) + else: + script.create_new_nightly_release_action(context) + + get_nightly_metadata_mock.assert_called_with(context.ship_it_instance_config, "firefox", "nightly", "20260525000000") + if expect_create: + create_new_nightly_release_mock.assert_called_with( + context.ship_it_instance_config, "firefox", "nightly", "20260525000000", "150.0a1", ["en-US", "de"] + ) + else: + create_new_nightly_release_mock.assert_not_called() + + def test_get_default_config(): parent_dir = os.path.dirname(os.getcwd()) data_dir = os.path.join(os.path.dirname(shipitscript.__file__), "data") @@ -103,6 +146,7 @@ def test_get_default_config(): "mark_as_shipped_schema_file": os.path.join(data_dir, "mark_as_shipped_task_schema.json"), "mark_as_merged_schema_file": os.path.join(data_dir, "mark_as_merged_task_schema.json"), "create_new_release_schema_file": os.path.join(data_dir, "create_new_release_task_schema.json"), + "create_new_nightly_release_schema_file": os.path.join(data_dir, "create_new_nightly_release_task_schema.json"), } diff --git a/shipitscript/tests/test_ship_actions.py b/shipitscript/tests/test_ship_actions.py index 81011d233..33daae302 100644 --- a/shipitscript/tests/test_ship_actions.py +++ b/shipitscript/tests/test_ship_actions.py @@ -48,3 +48,53 @@ def test_mark_as_merged(monkeypatch, timeout, expected_timeout): taskcluster_client_id="some-id", taskcluster_access_token="some-token", api_root="http://some.ship-it.tld/api/root", timeout=expected_timeout ) release_instance_mock.complete_merge_automation.assert_called_with(123, headers={"X-Forwarded-Proto": "https", "X-Forwarded-Port": "80"}) + + +@pytest.mark.parametrize("timeout, expected_timeout", ((1, 1), ("10", 10), (None, 60))) +def test_get_nightly_metadata(monkeypatch, timeout, expected_timeout): + ReleaseClassMock = MagicMock() + release_instance_mock = MagicMock() + nightly_metadata = {"version": "150.0a1", "locales": ["en-US", "de"]} + attrs = {"get_nightly_metadata.return_value": nightly_metadata} + release_instance_mock.configure_mock(**attrs) + ReleaseClassMock.side_effect = lambda *args, **kwargs: release_instance_mock + monkeypatch.setattr(shipitscript.ship_actions, "Release_V2", ReleaseClassMock) + + ship_it_instance_config = {"taskcluster_client_id": "some-id", "taskcluster_access_token": "some-token", "api_root_v2": "http://some.ship-it.tld/api/root"} + if timeout is not None: + ship_it_instance_config["timeout_in_seconds"] = timeout + + ret = shipitscript.ship_actions.get_nightly_metadata(ship_it_instance_config, "firefox", "nightly", "20260525000000") + + assert ret == nightly_metadata + ReleaseClassMock.assert_called_with( + taskcluster_client_id="some-id", taskcluster_access_token="some-token", api_root="http://some.ship-it.tld/api/root", timeout=expected_timeout + ) + release_instance_mock.get_nightly_metadata.assert_called_with( + "firefox", "nightly", "20260525000000", headers={"X-Forwarded-Proto": "https", "X-Forwarded-Port": "80"} + ) + + +@pytest.mark.parametrize("timeout, expected_timeout", ((1, 1), ("10", 10), (None, 60))) +def test_create_new_nightly_release(monkeypatch, timeout, expected_timeout): + ReleaseClassMock = MagicMock() + release_instance_mock = MagicMock() + attrs = {"create_new_nightly_release.return_value": {"message": "ok"}} + release_instance_mock.configure_mock(**attrs) + ReleaseClassMock.side_effect = lambda *args, **kwargs: release_instance_mock + monkeypatch.setattr(shipitscript.ship_actions, "Release_V2", ReleaseClassMock) + + ship_it_instance_config = {"taskcluster_client_id": "some-id", "taskcluster_access_token": "some-token", "api_root_v2": "http://some.ship-it.tld/api/root"} + if timeout is not None: + ship_it_instance_config["timeout_in_seconds"] = timeout + + shipitscript.ship_actions.create_new_nightly_release( + ship_it_instance_config, "firefox", "nightly", "20260525000000", "150.0a1", ["en-US", "de"] + ) + + ReleaseClassMock.assert_called_with( + taskcluster_client_id="some-id", taskcluster_access_token="some-token", api_root="http://some.ship-it.tld/api/root", timeout=expected_timeout + ) + release_instance_mock.create_new_nightly_release.assert_called_with( + "firefox", "nightly", "20260525000000", "150.0a1", ["en-US", "de"], headers={"X-Forwarded-Proto": "https", "X-Forwarded-Port": "80"} + ) diff --git a/shipitscript/tests/test_shipitapi.py b/shipitscript/tests/test_shipitapi.py index 1d372dbbd..e6f40466d 100644 --- a/shipitscript/tests/test_shipitapi.py +++ b/shipitscript/tests/test_shipitapi.py @@ -89,3 +89,63 @@ def __init__(self): release.session.request.return_value.content = "Not JSON at all" with pytest.raises(json.decoder.JSONDecodeError): release.getRelease(release_name) + + +def test_release_v2_nightly_release(mocker): + class MockResponse(requests.Response): + _payload = {"success": True, "test": True} + + def __init__(self): + super(MockResponse, self).__init__() + self.status_code = 200 + + def json(self): + return self._payload + + release = Release_V2(taskcluster_client_id="some-id", taskcluster_access_token="some-token", api_root="https://www.apiroot.com/", retry_attempts=1) + mocker.patch.object(release, "session") + release.session.request.return_value = MockResponse() + api_call_count = 0 + + # test that get_nightly_metadata calls correct URL with query string + headers = {"X-Test": "yes"} + ret = release.get_nightly_metadata("firefox", "nightly", "20260525000000", headers=headers) + assert ret["test"] is True + correct_url = "https://www.apiroot.com/nightly-release?product=firefox&channel=nightly&buildid=20260525000000" + release.session.request.assert_called_with(data=None, headers={"X-Test": "yes"}, method="GET", timeout=mock.ANY, verify=mock.ANY, url=correct_url) + api_call_count += 1 + assert release.session.request.call_count == api_call_count + # make sure we don't modify the passed headers dictionary in the methods + assert headers == {"X-Test": "yes"} + + # test that get_nightly_metadata works with no headers + ret = release.get_nightly_metadata("firefox", "nightly", "20260525000000") + assert ret["test"] is True + release.session.request.assert_called_with(data=None, headers={}, method="GET", timeout=mock.ANY, verify=mock.ANY, url=correct_url) + api_call_count += 1 + assert release.session.request.call_count == api_call_count + + # test that create_new_nightly_release calls correct URL + headers = {"X-Test": "yes"} + ret = release.create_new_nightly_release("firefox", "nightly", "20260525000000", "150.0a1", ["en-US", "de"], headers=headers) + assert ret["test"] is True + correct_url = "https://www.apiroot.com/nightly-release" + expected_data = json.dumps( + { + "product": "firefox", + "channel": "nightly", + "buildid": "20260525000000", + "version": "150.0a1", + "locales": ["en-US", "de"], + } + ) + release.session.request.assert_called_with(data=expected_data, headers=mock.ANY, method="POST", timeout=mock.ANY, verify=mock.ANY, url=correct_url) + api_call_count += 1 + assert release.session.request.call_count == api_call_count + assert headers == {"X-Test": "yes"} + + # test that exception is raised on error, and api call is retried + release.session.request.return_value.status_code = 400 + with pytest.raises(requests.exceptions.HTTPError): + release.get_nightly_metadata("firefox", "nightly", "20260525000000") + assert release.session.request.call_count == api_call_count + release.retries