Skip to content

Commit 61c332b

Browse files
authored
feat(shipitscript): add support for creating new nightly releases (#1449)
1 parent 44b10fa commit 61c332b

8 files changed

Lines changed: 277 additions & 0 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"title": "Taskcluster ShipIt create new nightly release task minimal schema",
3+
"type": "object",
4+
"properties": {
5+
"dependencies": {
6+
"type": "array",
7+
"minItems": 1,
8+
"uniqueItems": true,
9+
"items": {
10+
"type": "string"
11+
}
12+
},
13+
"scopes": {
14+
"type": "array",
15+
"minItems": 2,
16+
"uniqueItems": true,
17+
"items": {
18+
"type": "string"
19+
}
20+
},
21+
"payload": {
22+
"type": "object",
23+
"properties": {
24+
"product": {
25+
"type": "string"
26+
},
27+
"channel": {
28+
"type": "string"
29+
},
30+
"buildid": {
31+
"type": "string"
32+
},
33+
"version": {
34+
"type": "string"
35+
},
36+
"locales": {
37+
"type": "array",
38+
"minItems": 1,
39+
"uniqueItems": true,
40+
"items": {
41+
"type": "string"
42+
}
43+
}
44+
},
45+
"required": ["product", "channel", "buildid", "version", "locales"],
46+
"additionalProperties": false
47+
}
48+
},
49+
"required": ["dependencies", "scopes", "payload"]
50+
}

shipitscript/src/shipitscript/script.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,42 @@ def update_product_channel_version_action(context):
114114
ship_actions.update_product_channel_version(shipit_config, product, channel, version)
115115

116116

117+
def create_new_nightly_release_action(context):
118+
payload = context.task["payload"]
119+
shipit_config = context.ship_it_instance_config
120+
product = payload["product"]
121+
channel = payload["channel"]
122+
version = payload["version"]
123+
buildid = payload["buildid"]
124+
locales = payload["locales"]
125+
nightly = ship_actions.get_nightly_metadata(shipit_config, product, channel, buildid)
126+
if nightly:
127+
if len(nightly) != 1:
128+
raise TaskVerificationError("Found multiple nightlies for the same buildid?")
129+
130+
nightly = nightly[0]
131+
if nightly["version"] != version:
132+
log.error(f"Nightly already exists; version does not match: {nightly['version']}")
133+
sys.exit(1)
134+
135+
submitted_locales = set(locales)
136+
existing_locales = set(nightly["locales"])
137+
if mismatch := submitted_locales.symmetric_difference(existing_locales):
138+
log.error(f"Nightly already exists; missing/added locales: {mismatch}")
139+
sys.exit(1)
140+
141+
log.info("Nightly already exists with matching data; doing nothing.")
142+
else:
143+
ship_actions.create_new_nightly_release(shipit_config, product, channel, buildid, version, locales)
144+
145+
117146
# ACTION_MAP {{{1
118147
ACTION_MAP = {
119148
"mark-as-shipped": mark_as_shipped_action,
120149
"mark-as-merged": mark_as_merged_action,
121150
"create-new-release": create_new_release_action,
122151
"update-product-channel-version": update_product_channel_version_action,
152+
"create-new-nightly-release": create_new_nightly_release_action,
123153
}
124154

125155

@@ -134,6 +164,7 @@ def get_default_config():
134164
"mark_as_shipped_schema_file": os.path.join(data_dir, "mark_as_shipped_task_schema.json"),
135165
"mark_as_merged_schema_file": os.path.join(data_dir, "mark_as_merged_task_schema.json"),
136166
"create_new_release_schema_file": os.path.join(data_dir, "create_new_release_task_schema.json"),
167+
"create_new_nightly_release_schema_file": os.path.join(data_dir, "create_new_nightly_release_task_schema.json"),
137168
}
138169

139170

shipitscript/src/shipitscript/ship_actions.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,16 @@ def update_product_channel_version(shipit_config, product, channel, version):
123123
log.info(f"Updating the current version of {product} {channel} to {version}...")
124124
response = release_api.update_product_channel_version(product, channel, version, headers=headers)
125125
log.info(response["message"])
126+
127+
128+
def get_nightly_metadata(shipit_config, product, channel, buildid):
129+
release_api, headers = get_shipit_api_instance(shipit_config)
130+
log.info(f"Getting metadata for nightly: {product}, {channel}, {buildid}")
131+
return release_api.get_nightly_metadata(product, channel, buildid, headers=headers)
132+
133+
134+
def create_new_nightly_release(shipit_config, product, channel, buildid, version, locales):
135+
release_api, headers = get_shipit_api_instance(shipit_config)
136+
log.info(f"Creating new nightly release: {product}, {channel}, {buildid}")
137+
release_api.create_new_nightly_release(product, channel, buildid, version, locales, headers=headers)
138+
log.info("New nightly release successfully created")

shipitscript/src/shipitscript/shipitapi.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,32 @@ def complete_merge_automation(self, automation_id, headers={}):
189189
"""
190190
resp = self._request(api_endpoint=f"/merge-automation/{automation_id}", method="PATCH", data="", headers=headers).content
191191
return resp
192+
193+
def get_nightly_metadata(self, product, channel, buildid, headers=None):
194+
headers = headers if headers else {}
195+
params = {"product": product, "channel": channel, "buildid": buildid}
196+
197+
try:
198+
response = self._request(api_endpoint=f"/nightly-release?{urllib.parse.urlencode(params)}", method="GET", headers=headers)
199+
return response.json()
200+
except Exception:
201+
log.error(f"Caught error while getting metadata for {product}, {channel}, {buildid}!", exc_info=True)
202+
raise
203+
204+
def create_new_nightly_release(self, product, channel, buildid, version, locales, headers=None):
205+
headers = headers if headers else {}
206+
data = json.dumps(
207+
{
208+
"product": product,
209+
"channel": channel,
210+
"buildid": buildid,
211+
"version": version,
212+
"locales": locales,
213+
}
214+
)
215+
try:
216+
response = self._request(api_endpoint="/nightly-release", method="POST", data=data, headers=headers)
217+
return response.json()
218+
except Exception:
219+
log.error(f"Caught error while creating new nightly release: {product}, {channel}, {buildid}", exc_info=True)
220+
raise

shipitscript/src/shipitscript/task.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"mark-as-merged": "mark_as_merged_schema_file",
1313
"create-new-release": "create_new_release_schema_file",
1414
"update-product-channel-version": "update_product_channel_version_schema_file",
15+
"create-new-nightly-release": "create_new_nightly_release_schema_file",
1516
}
1617

1718

shipitscript/tests/test_script.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,49 @@ async def test_async_main(context, monkeypatch, task, raises):
9494
await script.async_main(context)
9595

9696

97+
@pytest.mark.parametrize(
98+
"existing_nightly,expect_create,expect_exit",
99+
(
100+
# no existing nightly: create one
101+
(None, True, False),
102+
# existing nightly matches version + locales: no-op
103+
([{"version": "150.0a1", "locales": ["en-US", "de"]}], False, False),
104+
# existing nightly mismatches version: sys.exit(1)
105+
([{"version": "149.0a1", "locales": ["en-US", "de"]}], False, True),
106+
# existing nightly mismatches locales: sys.exit(1)
107+
([{"version": "150.0a1", "locales": ["en-US"]}], False, True),
108+
),
109+
)
110+
def test_create_new_nightly_release_action(context, monkeypatch, existing_nightly, expect_create, expect_exit):
111+
context.ship_it_instance_config = context.config["shipit_instance"]
112+
context.task["payload"] = {
113+
"product": "firefox",
114+
"channel": "nightly",
115+
"buildid": "20260525000000",
116+
"version": "150.0a1",
117+
"locales": ["en-US", "de"],
118+
}
119+
120+
get_nightly_metadata_mock = MagicMock(return_value=existing_nightly)
121+
create_new_nightly_release_mock = MagicMock()
122+
monkeypatch.setattr(ship_actions, "get_nightly_metadata", get_nightly_metadata_mock)
123+
monkeypatch.setattr(ship_actions, "create_new_nightly_release", create_new_nightly_release_mock)
124+
125+
if expect_exit:
126+
with pytest.raises(SystemExit):
127+
script.create_new_nightly_release_action(context)
128+
else:
129+
script.create_new_nightly_release_action(context)
130+
131+
get_nightly_metadata_mock.assert_called_with(context.ship_it_instance_config, "firefox", "nightly", "20260525000000")
132+
if expect_create:
133+
create_new_nightly_release_mock.assert_called_with(
134+
context.ship_it_instance_config, "firefox", "nightly", "20260525000000", "150.0a1", ["en-US", "de"]
135+
)
136+
else:
137+
create_new_nightly_release_mock.assert_not_called()
138+
139+
97140
def test_get_default_config():
98141
parent_dir = os.path.dirname(os.getcwd())
99142
data_dir = os.path.join(os.path.dirname(shipitscript.__file__), "data")
@@ -103,6 +146,7 @@ def test_get_default_config():
103146
"mark_as_shipped_schema_file": os.path.join(data_dir, "mark_as_shipped_task_schema.json"),
104147
"mark_as_merged_schema_file": os.path.join(data_dir, "mark_as_merged_task_schema.json"),
105148
"create_new_release_schema_file": os.path.join(data_dir, "create_new_release_task_schema.json"),
149+
"create_new_nightly_release_schema_file": os.path.join(data_dir, "create_new_nightly_release_task_schema.json"),
106150
}
107151

108152

shipitscript/tests/test_ship_actions.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,52 @@ def test_mark_as_merged(monkeypatch, timeout, expected_timeout):
4848
taskcluster_client_id="some-id", taskcluster_access_token="some-token", api_root="http://some.ship-it.tld/api/root", timeout=expected_timeout
4949
)
5050
release_instance_mock.complete_merge_automation.assert_called_with(123, headers={"X-Forwarded-Proto": "https", "X-Forwarded-Port": "80"})
51+
52+
53+
@pytest.mark.parametrize("timeout, expected_timeout", ((1, 1), ("10", 10), (None, 60)))
54+
def test_get_nightly_metadata(monkeypatch, timeout, expected_timeout):
55+
ReleaseClassMock = MagicMock()
56+
release_instance_mock = MagicMock()
57+
nightly_metadata = [{"version": "150.0a1", "locales": ["en-US", "de"]}]
58+
attrs = {"get_nightly_metadata.return_value": nightly_metadata}
59+
release_instance_mock.configure_mock(**attrs)
60+
ReleaseClassMock.side_effect = lambda *args, **kwargs: release_instance_mock
61+
monkeypatch.setattr(shipitscript.ship_actions, "Release_V2", ReleaseClassMock)
62+
63+
ship_it_instance_config = {"taskcluster_client_id": "some-id", "taskcluster_access_token": "some-token", "api_root_v2": "http://some.ship-it.tld/api/root"}
64+
if timeout is not None:
65+
ship_it_instance_config["timeout_in_seconds"] = timeout
66+
67+
ret = shipitscript.ship_actions.get_nightly_metadata(ship_it_instance_config, "firefox", "nightly", "20260525000000")
68+
69+
ReleaseClassMock.assert_called_with(
70+
taskcluster_client_id="some-id", taskcluster_access_token="some-token", api_root="http://some.ship-it.tld/api/root", timeout=expected_timeout
71+
)
72+
release_instance_mock.get_nightly_metadata.assert_called_with(
73+
"firefox", "nightly", "20260525000000", headers={"X-Forwarded-Proto": "https", "X-Forwarded-Port": "80"}
74+
)
75+
76+
77+
@pytest.mark.parametrize("timeout, expected_timeout", ((1, 1), ("10", 10), (None, 60)))
78+
def test_create_new_nightly_release(monkeypatch, timeout, expected_timeout):
79+
ReleaseClassMock = MagicMock()
80+
release_instance_mock = MagicMock()
81+
attrs = {"create_new_nightly_release.return_value": {"message": "ok"}}
82+
release_instance_mock.configure_mock(**attrs)
83+
ReleaseClassMock.side_effect = lambda *args, **kwargs: release_instance_mock
84+
monkeypatch.setattr(shipitscript.ship_actions, "Release_V2", ReleaseClassMock)
85+
86+
ship_it_instance_config = {"taskcluster_client_id": "some-id", "taskcluster_access_token": "some-token", "api_root_v2": "http://some.ship-it.tld/api/root"}
87+
if timeout is not None:
88+
ship_it_instance_config["timeout_in_seconds"] = timeout
89+
90+
shipitscript.ship_actions.create_new_nightly_release(
91+
ship_it_instance_config, "firefox", "nightly", "20260525000000", "150.0a1", ["en-US", "de"]
92+
)
93+
94+
ReleaseClassMock.assert_called_with(
95+
taskcluster_client_id="some-id", taskcluster_access_token="some-token", api_root="http://some.ship-it.tld/api/root", timeout=expected_timeout
96+
)
97+
release_instance_mock.create_new_nightly_release.assert_called_with(
98+
"firefox", "nightly", "20260525000000", "150.0a1", ["en-US", "de"], headers={"X-Forwarded-Proto": "https", "X-Forwarded-Port": "80"}
99+
)

shipitscript/tests/test_shipitapi.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,63 @@ def __init__(self):
8989
release.session.request.return_value.content = "Not JSON at all"
9090
with pytest.raises(json.decoder.JSONDecodeError):
9191
release.getRelease(release_name)
92+
93+
94+
def test_release_v2_nightly_release(mocker):
95+
class MockResponse(requests.Response):
96+
_payload = {"success": True, "test": True}
97+
98+
def __init__(self):
99+
super(MockResponse, self).__init__()
100+
self.status_code = 200
101+
102+
def json(self):
103+
return self._payload
104+
105+
release = Release_V2(taskcluster_client_id="some-id", taskcluster_access_token="some-token", api_root="https://www.apiroot.com/", retry_attempts=1)
106+
mocker.patch.object(release, "session")
107+
release.session.request.return_value = MockResponse()
108+
api_call_count = 0
109+
110+
# test that get_nightly_metadata calls correct URL with query string
111+
headers = {"X-Test": "yes"}
112+
ret = release.get_nightly_metadata("firefox", "nightly", "20260525000000", headers=headers)
113+
assert ret["test"] is True
114+
correct_url = "https://www.apiroot.com/nightly-release?product=firefox&channel=nightly&buildid=20260525000000"
115+
release.session.request.assert_called_with(data=None, headers={"X-Test": "yes"}, method="GET", timeout=mock.ANY, verify=mock.ANY, url=correct_url)
116+
api_call_count += 1
117+
assert release.session.request.call_count == api_call_count
118+
# make sure we don't modify the passed headers dictionary in the methods
119+
assert headers == {"X-Test": "yes"}
120+
121+
# test that get_nightly_metadata works with no headers
122+
ret = release.get_nightly_metadata("firefox", "nightly", "20260525000000")
123+
assert ret["test"] is True
124+
release.session.request.assert_called_with(data=None, headers={}, method="GET", timeout=mock.ANY, verify=mock.ANY, url=correct_url)
125+
api_call_count += 1
126+
assert release.session.request.call_count == api_call_count
127+
128+
# test that create_new_nightly_release calls correct URL
129+
headers = {"X-Test": "yes"}
130+
ret = release.create_new_nightly_release("firefox", "nightly", "20260525000000", "150.0a1", ["en-US", "de"], headers=headers)
131+
assert ret["test"] is True
132+
correct_url = "https://www.apiroot.com/nightly-release"
133+
expected_data = json.dumps(
134+
{
135+
"product": "firefox",
136+
"channel": "nightly",
137+
"buildid": "20260525000000",
138+
"version": "150.0a1",
139+
"locales": ["en-US", "de"],
140+
}
141+
)
142+
release.session.request.assert_called_with(data=expected_data, headers=mock.ANY, method="POST", timeout=mock.ANY, verify=mock.ANY, url=correct_url)
143+
api_call_count += 1
144+
assert release.session.request.call_count == api_call_count
145+
assert headers == {"X-Test": "yes"}
146+
147+
# test that exception is raised on error, and api call is retried
148+
release.session.request.return_value.status_code = 400
149+
with pytest.raises(requests.exceptions.HTTPError):
150+
release.get_nightly_metadata("firefox", "nightly", "20260525000000")
151+
assert release.session.request.call_count == api_call_count + release.retries

0 commit comments

Comments
 (0)