From 71a95abab527aab4c202f694e91de79858749aee Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:11:18 +0500 Subject: [PATCH 01/16] fix(deps): update dependency mitol-django-mail to v2026 (#4041) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d4705cf6a..d1f39218f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "mitol-django-common==2026.6.16.4", "mitol-django-digital-credentials==2023.12.19", "mitol-django-hubspot-api==2026.7.9", - "mitol-django-mail==2025.6.24", + "mitol-django-mail==2026.4.29", "mitol-django-oauth-toolkit-extensions==2025.3.17", "mitol-django-observability>=2026.1.0", "mitol-django-olposthog>=2026.3.6,<2027", diff --git a/uv.lock b/uv.lock index 3160343b9..4ba712538 100644 --- a/uv.lock +++ b/uv.lock @@ -1841,7 +1841,7 @@ wheels = [ [[package]] name = "mitol-django-mail" -version = "2025.6.24" +version = "2026.4.29" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4" }, @@ -1853,9 +1853,9 @@ dependencies = [ { name = "premailer" }, { name = "toolz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/82/ca6814b0fa4ebaf0ab1c9e93c1a2b60da7f198e8b13823d0adaac15a404f/mitol_django_mail-2025.6.24.tar.gz", hash = "sha256:7acaec76138b35bf20af8d33e55c575a829c9e7f6d28f02246da720147c1adc9", size = 13537, upload-time = "2025-06-24T22:12:12.8Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/93/599cacd89feedbafb1996f61f762c26fc48a22107873ee24355d3af2dec6/mitol_django_mail-2026.4.29.tar.gz", hash = "sha256:9ba4f8dc32950c1796c5121d29346db3be8a205cf60e65af7203feec5e61994d", size = 13642, upload-time = "2026-04-29T21:17:52.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/6d/8740fa49df7e729c87d9379c94b44ae1adc46be9bab6cbb7a42e918f786d/mitol_django_mail-2025.6.24-py3-none-any.whl", hash = "sha256:20e1830de82095fa1bc5484275c036a45b207e991f9febd65b4d9e276ffd4f57", size = 18971, upload-time = "2025-06-24T22:12:11.721Z" }, + { url = "https://files.pythonhosted.org/packages/38/3b/6bd15115bdf0197ba38f7d4f2f21556a9cc1abf61eeab1fcdd8d26892005/mitol_django_mail-2026.4.29-py3-none-any.whl", hash = "sha256:77e42ba048e59764cc2abd0b403515be8395a54afb5bf69a36c506bbee132406", size = 18935, upload-time = "2026-04-29T21:17:51.117Z" }, ] [[package]] @@ -2038,7 +2038,7 @@ requires-dist = [ { name = "mitol-django-common", specifier = "==2026.6.16.4" }, { name = "mitol-django-digital-credentials", specifier = "==2023.12.19" }, { name = "mitol-django-hubspot-api", specifier = "==2026.7.9" }, - { name = "mitol-django-mail", specifier = "==2025.6.24" }, + { name = "mitol-django-mail", specifier = "==2026.4.29" }, { name = "mitol-django-oauth-toolkit-extensions", specifier = "==2025.3.17" }, { name = "mitol-django-observability", specifier = ">=2026.1.0" }, { name = "mitol-django-olposthog", specifier = ">=2026.3.6,<2027" }, From 878de56de26fc0c504078be36f29805a76a5abf4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:48:42 +0500 Subject: [PATCH 02/16] fix(deps): update dependency mitol-django-authentication to v2026 (#4040) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d1f39218f..0eac6956d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies = [ "granian>=2.7.2", "hubspot-api-client==12.0.0", "ipython>=9.0.0,<10", - "mitol-django-authentication==2025.3.17", + "mitol-django-authentication==2026.4.29", "mitol-django-common==2026.6.16.4", "mitol-django-digital-credentials==2023.12.19", "mitol-django-hubspot-api==2026.7.9", diff --git a/uv.lock b/uv.lock index 4ba712538..bde1225a2 100644 --- a/uv.lock +++ b/uv.lock @@ -933,16 +933,17 @@ wheels = [ [[package]] name = "djoser" -version = "2.3.1" +version = "2.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, + { name = "djangorestframework" }, { name = "djangorestframework-simplejwt" }, { name = "social-auth-app-django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/01/4a/d5bb069b49199c9fd0dc33c25dd7a6c15152926c47d73a560b6fde3bd2fb/djoser-2.3.1.tar.gz", hash = "sha256:4e7e2716b5b961f1289b5e49b2216ba5c18eb2a3b4b597dd6430638716ff5107", size = 33806, upload-time = "2024-11-09T16:57:50.682Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/bc/8931752c12ddc987fc0c729e9b675e2f72e37ebd82f7ca31a10f287de045/djoser-2.3.3.tar.gz", hash = "sha256:6ceeea9898cbdd585f1daa1ee9d46270600c0401dcd2d1db6f7894782006f6a6", size = 35032, upload-time = "2025-07-13T14:36:03.38Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/77/eae6f6ae6b71d578d08f7eee7c0965cff59a270cf024ecdcbb8048fc7a4b/djoser-2.3.1-py3-none-any.whl", hash = "sha256:386f337b9e05cb82354525fe2b6fec19fb1743e93e53b5b4b9dfaffccc38789f", size = 64215, upload-time = "2024-11-09T16:57:49.603Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/f51273281172ff233a8c16df916282d75502dbc6a06b9b5d01ed3039f8ed/djoser-2.3.3-py3-none-any.whl", hash = "sha256:b97d233b626c26ebccb09f5614420873ad78b8b1fb1459c76475b05319bae567", size = 71905, upload-time = "2025-07-13T14:36:02.385Z" }, ] [[package]] @@ -1768,7 +1769,7 @@ wheels = [ [[package]] name = "mitol-django-authentication" -version = "2025.3.17" +version = "2026.4.29" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, @@ -1780,9 +1781,9 @@ dependencies = [ { name = "mitol-django-mail" }, { name = "social-auth-app-django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/9a/4633c6b97a88f7a56992b03a80f30e86b54c7b7ef65489df854e304879c0/mitol_django_authentication-2025.3.17.tar.gz", hash = "sha256:296f3f4ef03722560da5435fc16d5844fc6bbbb8685fdce325dbc95044354952", size = 5887, upload-time = "2025-03-17T20:13:06.758Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/9d/14c5f7883a435d27bb3f629911e28babc3618db42644c1113de0f82f15b5/mitol_django_authentication-2026.4.29.tar.gz", hash = "sha256:ce2d5e4d9ca2fa524c7b748915ccfa8af19c7a2c02fdcc9a5ca1534206c53698", size = 6034, upload-time = "2026-04-29T19:56:16.736Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/c8/a6fa787ecfc4e73e03a8c7418e111a6a957d2e24179d182cf83c123641d4/mitol_django_authentication-2025.3.17-py3-none-any.whl", hash = "sha256:f3849bc2469bcbe78fed9b0e999dfed2bf6c9d2923c4e2420d6d54a71f8a99bb", size = 10138, upload-time = "2025-03-17T20:13:05.668Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/fd8c8549d2b363333d75f6259eb84ea16148901cd329abbdf5199d899d34/mitol_django_authentication-2026.4.29-py3-none-any.whl", hash = "sha256:95590074644f0772c10b2507d806afe398559ca86841a2a070736c945aeaedba", size = 10139, upload-time = "2026-04-29T19:56:15.873Z" }, ] [[package]] @@ -2034,7 +2035,7 @@ requires-dist = [ { name = "granian", specifier = ">=2.7.2" }, { name = "hubspot-api-client", specifier = "==12.0.0" }, { name = "ipython", specifier = ">=9.0.0,<10" }, - { name = "mitol-django-authentication", specifier = "==2025.3.17" }, + { name = "mitol-django-authentication", specifier = "==2026.4.29" }, { name = "mitol-django-common", specifier = "==2026.6.16.4" }, { name = "mitol-django-digital-credentials", specifier = "==2023.12.19" }, { name = "mitol-django-hubspot-api", specifier = "==2026.7.9" }, From a030ea4b3c8227b3dffed259838f0918433008d0 Mon Sep 17 00:00:00 2001 From: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:10:14 +0500 Subject: [PATCH 03/16] fix: make sync_db_to_hubspot resilient to bad data (#4019) * fix: make sync_db_to_hubspot resilient to bad data * test: add tests * fix: issues * fix: issues * fix: issues * fix: issues * fix: issues * fix: issues * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: issue --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- hubspot_xpro/api.py | 43 +++++- hubspot_xpro/api_test.py | 53 +++++++ hubspot_xpro/tasks.py | 269 +++++++++++++++++++++++++--------- hubspot_xpro/tasks_test.py | 288 ++++++++++++++++++++++++++++++++++++- 4 files changed, 572 insertions(+), 81 deletions(-) diff --git a/hubspot_xpro/api.py b/hubspot_xpro/api.py index 7069b7a5a..0e08fb818 100644 --- a/hubspot_xpro/api.py +++ b/hubspot_xpro/api.py @@ -5,6 +5,7 @@ from decimal import Decimal from django.contrib.contenttypes.models import ContentType +from django.db import IntegrityError, transaction from django.db.models import Q from hubspot.crm.objects import SimplePublicObject, SimplePublicObjectInput from mitol.hubspot_api.api import ( @@ -433,11 +434,43 @@ def get_hubspot_id_for_object( raise_count_error=raise_error, ) if hubspot_obj and hubspot_obj.id: # noqa: RET503 - HubspotObject.objects.update_or_create( - object_id=obj.id, - content_type=content_type, - defaults={"hubspot_id": hubspot_obj.id}, - ) + try: + # Savepoint so we can catch IntegrityError and keep querying: on + # Postgres a failed statement aborts the whole transaction, and the + # atomic() block rolls back to the savepoint to keep the connection + # usable. See + # https://docs.djangoproject.com/en/stable/topics/db/transactions/#controlling-transactions-explicitly + with transaction.atomic(): + HubspotObject.objects.update_or_create( + object_id=obj.id, + content_type=content_type, + defaults={"hubspot_id": hubspot_obj.id}, + ) + except IntegrityError: + mapping_conflict = ( + HubspotObject.objects.filter( + content_type=content_type, hubspot_id=hubspot_obj.id + ) + .exclude(object_id=obj.id) + .exists() + ) + if not mapping_conflict: + # Not the expected duplicate-mapping conflict; surface the real + # DB integrity error rather than returning a hubspot id and + # continuing with a potentially inconsistent DB state. + raise + # The found hubspot id is already mapped to a different object of + # this content type (e.g. a duplicate-named product). Don't create a + # conflicting mapping, but still return the hubspot id so callers can + # proceed instead of failing the whole sync. + log.warning( + "Hubspot %s %s is already mapped to a different object; not " + "remapping %s %s", + content_type.model, + hubspot_obj.id, + content_type.model, + obj.id, + ) return hubspot_obj.id elif raise_error: raise ValueError( diff --git a/hubspot_xpro/api_test.py b/hubspot_xpro/api_test.py index ff3248599..aed46caf9 100644 --- a/hubspot_xpro/api_test.py +++ b/hubspot_xpro/api_test.py @@ -2,6 +2,7 @@ import pytest from django.contrib.contenttypes.models import ContentType +from django.db import IntegrityError from mitol.hubspot_api.factories import HubspotObjectFactory, SimplePublicObjectFactory from mitol.hubspot_api.models import HubspotObject @@ -357,6 +358,58 @@ def test_sync_product_hubspot_ids_dupe_names(mocker, mock_hubspot_api): assert HubspotObject.objects.filter(content_type__model="product").count() == 2 +def test_get_hubspot_id_for_object_skips_conflicting_mapping(mocker): + """ + When a lookup resolves to a hubspot id already mapped to a different object + (e.g. a duplicate-named product), get_hubspot_id_for_object should return the + hubspot id without raising IntegrityError or creating a conflicting mapping. + """ + existing_product = ProductFactory.create() + conflicting_product = ProductFactory.create() + content_type = ContentType.objects.get_for_model(Product) + HubspotObject.objects.create( + content_type=content_type, + object_id=existing_product.id, + hubspot_id="999", + ) + mocker.patch( + "hubspot_xpro.api.find_product", + return_value=SimplePublicObjectFactory(id="999"), + ) + + result = api.get_hubspot_id_for_object(conflicting_product) + + assert result == "999" + # No conflicting mapping was created for the second product + assert not HubspotObject.objects.filter( + content_type=content_type, object_id=conflicting_product.id + ).exists() + # The original owner's mapping is intact + assert ( + HubspotObject.objects.get(content_type=content_type, hubspot_id="999").object_id + == existing_product.id + ) + + +def test_get_hubspot_id_for_object_reraises_unexpected_integrity_error(mocker): + """ + An IntegrityError that is not the expected duplicate-mapping conflict should + propagate rather than returning a hubspot id with an inconsistent DB state. + """ + product = ProductFactory.create() + mocker.patch( + "hubspot_xpro.api.find_product", + return_value=SimplePublicObjectFactory(id="555"), + ) + mocker.patch( + "hubspot_xpro.api.HubspotObject.objects.update_or_create", + side_effect=IntegrityError("unexpected db problem"), + ) + + with pytest.raises(IntegrityError): + api.get_hubspot_id_for_object(product) + + @pytest.mark.parametrize("match_all_lines", [True, False]) @pytest.mark.parametrize("match_all_deals", [True, False]) def test_sync_deal_hubspot_ids_to_hubspot( diff --git a/hubspot_xpro/tasks.py b/hubspot_xpro/tasks.py index 35930b6ec..c82bba524 100644 --- a/hubspot_xpro/tasks.py +++ b/hubspot_xpro/tasks.py @@ -9,6 +9,7 @@ import celery from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.db import IntegrityError, transaction from hubspot.crm.associations import BatchInputPublicAssociation, PublicAssociation from hubspot.crm.objects import ( ApiException, @@ -32,6 +33,17 @@ log = logging.getLogger() +def reraise_if_rate_limited(exc: Exception) -> None: + """ + Re-raise HubSpot rate-limit (429) errors so Celery's autoretry/backoff can + handle them. Any other exception is left for the caller to handle. + """ + if isinstance(exc, TooManyRequestsException) or ( + isinstance(exc, ApiException) and getattr(exc, "status", None) == 429 # noqa: PLR2004 + ): + raise exc + + def task_obj_lock(func_name: str, args: list[object], kwargs: dict) -> str: """ Determine a task lock name for a specific task function and object id @@ -101,7 +113,16 @@ def sync_failed_contacts(chunk: list[int]) -> list[int]: try: api.sync_contact_with_hubspot(user_id) time.sleep(settings.HUBSPOT_TASK_DELAY / 1000) - except ApiException: + except TooManyRequestsException: + raise + except ApiException as ae: + if getattr(ae, "status", None) == 429: # noqa: PLR2004 + raise + failed_ids.append(user_id) + except Exception: # noqa: BLE001 + log.exception( + "Could not sync hubspot contact for user %s; skipping", user_id + ) failed_ids.append(user_id) return failed_ids @@ -239,7 +260,13 @@ def batch_upsert_hubspot_deals_chunked(ids: list[int]): """ results = [] for order in Order.objects.filter(id__in=ids): - results.append(api.sync_deal_with_hubspot(order.id).id) + try: + results.append(api.sync_deal_with_hubspot(order.id).id) + except Exception as exc: # noqa: BLE001 + reraise_if_rate_limited(exc) + log.exception( + "Could not sync hubspot deal for order %s; skipping", order.id + ) time.sleep(settings.HUBSPOT_TASK_DELAY / 1000) return results @@ -264,7 +291,13 @@ def batch_upsert_hubspot_b2b_deals_chunked(ids: list[int]) -> list[str]: """ results = [] for order in B2BOrder.objects.filter(id__in=ids): - results.append(api.sync_b2b_deal_with_hubspot(order.id).id) + try: + results.append(api.sync_b2b_deal_with_hubspot(order.id).id) + except Exception as exc: # noqa: BLE001 + reraise_if_rate_limited(exc) + log.exception( + "Could not sync hubspot b2b deal for order %s; skipping", order.id + ) time.sleep(settings.HUBSPOT_TASK_DELAY / 1000) return results @@ -349,40 +382,102 @@ def batch_create_hubspot_objects_chunked( last_error_status = None for chunk in chunked_ids: try: + inputs = [] + for obj_id in chunk: + try: + inputs.append(api.MODEL_FUNCTION_MAPPING[ct_model_name](obj_id)) + except Exception as exc: # noqa: BLE001 + reraise_if_rate_limited(exc) + log.exception( + "Could not build hubspot %s sync message for %s %s; skipping", + hubspot_type, + ct_model_name, + obj_id, + ) + if not inputs: + continue response = HubspotApi().crm.objects.batch_api.create( hubspot_type, - BatchInputSimplePublicObjectBatchInputForCreate( - inputs=[ - api.MODEL_FUNCTION_MAPPING[ct_model_name](obj_id) - for obj_id in chunk - ] - ), + BatchInputSimplePublicObjectBatchInputForCreate(inputs=inputs), ) for result in response.results: - if ct_model_name == "user": - object_id = User.objects.get( - email__iexact=result.properties["email"], is_active=True - ).id - else: - object_id = result.properties["unique_app_id"].split("-")[-1] - HubspotObject.objects.update_or_create( - content_type=content_type, - hubspot_id=result.id, - object_id=object_id, - ) - created_ids.append(result.id) - except ApiException as ae: - last_error_status = ae.status + try: + if ct_model_name == "user": + try: + object_id = User.objects.get( + email__iexact=result.properties["email"], + is_active=True, + ).id + except (User.DoesNotExist, User.MultipleObjectsReturned): + log.exception( + "Could not resolve a unique active user for hubspot " + "contact %s (email %s); skipping", + result.id, + result.properties.get("email"), + ) + continue + else: + object_id = result.properties["unique_app_id"].split("-")[-1] + try: + # Savepoint so we can catch IntegrityError and keep + # querying: on Postgres a failed statement aborts the + # whole transaction, and the atomic() block rolls back to + # the savepoint to keep the connection usable. See + # https://docs.djangoproject.com/en/stable/topics/db/transactions/#controlling-transactions-explicitly + with transaction.atomic(): + HubspotObject.objects.update_or_create( + content_type=content_type, + hubspot_id=result.id, + object_id=object_id, + ) + except IntegrityError: + mapping_conflict = ( + HubspotObject.objects.filter( + content_type=content_type, hubspot_id=result.id + ) + .exclude(object_id=object_id) + .exists() + or HubspotObject.objects.filter( + content_type=content_type, object_id=object_id + ) + .exclude(hubspot_id=result.id) + .exists() + ) + if not mapping_conflict: + # Not the expected (hubspot_id/object_id, content_type) + # conflict; surface the real DB integrity problem + # instead of silently skipping the record. + raise + # The hubspot_id (or object_id) is already mapped to a + # different object for this content type. Skip rather than + # violating the unique (hubspot_id, content_type) constraint. + log.warning( + "Could not map hubspot %s %s to %s %s; an existing " + "mapping already claims it, skipping", + hubspot_type, + result.id, + ct_model_name, + object_id, + ) + continue + created_ids.append(result.id) + except (TooManyRequestsException, ApiException, IntegrityError): + raise + except Exception: # noqa: BLE001 + log.exception( + "Could not process hubspot %s result %s; skipping", + hubspot_type, + getattr(result, "id", None), + ) + continue + except ApiException as exc: + reraise_if_rate_limited(exc) + last_error_status = exc.status still_failed = handle_failed_batch_chunk(chunk, hubspot_type) if still_failed: errored_chunks.append(still_failed) time.sleep(settings.HUBSPOT_TASK_DELAY / 1000) if errored_chunks: - if last_error_status == 429: # noqa: PLR2004 - raise ApiException( - status=last_error_status, - reason=f"Batch hubspot create failed for the following chunks: {errored_chunks}", - ) log.error( "Batch hubspot create failed for type %s, chunks: %s (status %s)", hubspot_type, @@ -421,21 +516,34 @@ def batch_update_hubspot_objects_chunked( last_error_status = None for chunk in chunked_ids: try: - inputs = [ - { - "id": obj_id[1], - "properties": api.MODEL_FUNCTION_MAPPING[ct_model_name]( - obj_id[0] - ).properties, - } - for obj_id in chunk - ] + inputs = [] + for obj_id in chunk: + try: + inputs.append( + { + "id": obj_id[1], + "properties": api.MODEL_FUNCTION_MAPPING[ct_model_name]( + obj_id[0] + ).properties, + } + ) + except Exception as exc: # noqa: BLE001 + reraise_if_rate_limited(exc) + log.exception( + "Could not build hubspot %s update message for %s %s; skipping", + hubspot_type, + ct_model_name, + obj_id[0], + ) + if not inputs: + continue response = HubspotApi().crm.objects.batch_api.update( hubspot_type, BatchInputSimplePublicObjectBatchInput(inputs=inputs) ) updated_ids.extend([result.id for result in response.results]) - except ApiException as ae: - last_error_status = ae.status + except ApiException as exc: + reraise_if_rate_limited(exc) + last_error_status = exc.status still_failed = handle_failed_batch_chunk( [item[0] for item in chunk], hubspot_type ) @@ -517,11 +625,10 @@ def batch_upsert_associations_chunked(order_ids: list[int]): hubspot_client = HubspotApi() deal_count = len(order_ids) for idx, order_id in enumerate(order_ids): - deal = Order.objects.get(id=order_id) - contact_id = get_hubspot_id_for_object(deal.purchaser) - deal_id = get_hubspot_id_for_object(deal) - for line in deal.lines.iterator(): - line_id = get_hubspot_id_for_object(line) + try: + deal = Order.objects.get(id=order_id) + contact_id = get_hubspot_id_for_object(deal.purchaser) + deal_id = get_hubspot_id_for_object(deal) if contact_id and deal_id: contact_associations_batch.append( PublicAssociation( @@ -530,34 +637,56 @@ def batch_upsert_associations_chunked(order_ids: list[int]): type=HubspotAssociationType.DEAL_CONTACT.value, ) ) - if line_id and deal_id: - line_associations_batch.append( - PublicAssociation( - _from=line_id, - to=deal_id, - type=HubspotAssociationType.LINE_DEAL.value, + for line in deal.lines.iterator(): + line_id = get_hubspot_id_for_object(line) + if line_id and deal_id: + line_associations_batch.append( + PublicAssociation( + _from=line_id, + to=deal_id, + type=HubspotAssociationType.LINE_DEAL.value, + ) + ) + except Exception as exc: # noqa: BLE001 + reraise_if_rate_limited(exc) + log.exception( + "Could not gather hubspot associations for order %s; skipping", + order_id, + ) + if ( + len(contact_associations_batch) == 100 # noqa: PLR2004 + or len(line_associations_batch) == 100 # noqa: PLR2004 + or idx == deal_count - 1 + ): + if line_associations_batch: + try: + hubspot_client.crm.associations.batch_api.create( + HubspotObjectType.LINES.value, + HubspotObjectType.DEALS.value, + batch_input_public_association=BatchInputPublicAssociation( + inputs=line_associations_batch + ), + ) + except ApiException as exc: + reraise_if_rate_limited(exc) + log.exception( + "Could not create hubspot line-deal associations batch; skipping" ) - ) - if ( - len(contact_associations_batch) == 100 # noqa: PLR2004 - or len(line_associations_batch) == 100 # noqa: PLR2004 - or idx == deal_count - 1 - ): - hubspot_client.crm.associations.batch_api.create( - HubspotObjectType.LINES.value, - HubspotObjectType.DEALS.value, - batch_input_public_association=BatchInputPublicAssociation( - inputs=line_associations_batch - ), - ) line_associations_batch = [] - hubspot_client.crm.associations.batch_api.create( - HubspotObjectType.DEALS.value, - HubspotObjectType.CONTACTS.value, - batch_input_public_association=BatchInputPublicAssociation( - inputs=contact_associations_batch - ), - ) + if contact_associations_batch: + try: + hubspot_client.crm.associations.batch_api.create( + HubspotObjectType.DEALS.value, + HubspotObjectType.CONTACTS.value, + batch_input_public_association=BatchInputPublicAssociation( + inputs=contact_associations_batch + ), + ) + except ApiException as exc: + reraise_if_rate_limited(exc) + log.exception( + "Could not create hubspot deal-contact associations batch; skipping" + ) contact_associations_batch = [] return order_ids diff --git a/hubspot_xpro/tasks_test.py b/hubspot_xpro/tasks_test.py index 171eb2e01..8d3188ef3 100644 --- a/hubspot_xpro/tasks_test.py +++ b/hubspot_xpro/tasks_test.py @@ -7,6 +7,7 @@ import pytest from django.contrib.contenttypes.models import ContentType +from django.db import IntegrityError from faker import Faker from hubspot.crm.associations import BatchInputPublicAssociation, PublicAssociation from hubspot.crm.objects import ( @@ -115,6 +116,37 @@ def test_batch_upsert_hubspot_deals_chunked(mocker): assert result == [result.id for result in mock_results] +@pytest.mark.parametrize( + "order_factory, sync_func, task_func", # noqa: PT006 + [ + [OrderFactory, "sync_deal_with_hubspot", "batch_upsert_hubspot_deals_chunked"], # noqa: PT007 + [ # noqa: PT007 + B2BOrderFactory, + "sync_b2b_deal_with_hubspot", + "batch_upsert_hubspot_b2b_deals_chunked", + ], + ], +) +def test_batch_upsert_deals_chunked_skips_bad_order( + mocker, order_factory, sync_func, task_func +): + """A bad-data order is skipped while the rest of the chunk still syncs""" + orders = sorted(order_factory.create_batch(3), key=lambda order: order.id) + good_result = SimplePublicObjectFactory() + + def fake_sync(order_id): + if order_id == orders[1].id: + raise ValueError("bad data") + return good_result + + mock_sync_deal = mocker.patch( + f"hubspot_xpro.tasks.api.{sync_func}", side_effect=fake_sync + ) + result = getattr(tasks, task_func)([order.id for order in orders]) + assert mock_sync_deal.call_count == 3 + assert result == [good_result.id, good_result.id] + + @pytest.mark.parametrize("create", [True, False]) @pytest.mark.parametrize("max_batches", [20, 1]) def test_batch_upsert_b2b_hubspot_deals( @@ -265,8 +297,43 @@ def test_batch_update_hubspot_objects_chunked_error(mocker, status, expected_err "user", chunk, ) - for item in chunk: - mock_sync_contacts.assert_any_call(item[0]) + if status == 429: # noqa: PLR2004 + # A 429 from the batch call is re-raised immediately for backoff, + # without attempting individual retries + mock_sync_contacts.assert_not_called() + else: + for item in chunk: + mock_sync_contacts.assert_any_call(item[0]) + + +def test_batch_update_hubspot_objects_chunked_skips_unserializable(mocker): + """An object that can't be serialized is skipped while the rest still update""" + users = sorted(UserFactory.create_batch(2), key=lambda contact: contact.id) + chunk = [(contact.id, str(contact.id)) for contact in users] + good_message = SimplePublicObjectFactory() + + def fake_message(obj_id): + if obj_id == users[0].id: + raise ValueError("bad data") + return good_message + + mocker.patch.dict( + "hubspot_xpro.tasks.api.MODEL_FUNCTION_MAPPING", {"user": fake_message} + ) + mock_hubspot_api = mocker.patch("hubspot_xpro.tasks.HubspotApi") + mock_hubspot_api.return_value.crm.objects.batch_api.update.return_value = ( + mocker.Mock(results=[SimplePublicObjectFactory(id="999")]) + ) + result = tasks.batch_update_hubspot_objects_chunked( + HubspotObjectType.CONTACTS.value, "user", chunk + ) + sent_inputs = ( + mock_hubspot_api.return_value.crm.objects.batch_api.update.call_args.args[ + 1 + ].inputs + ) + assert len(sent_inputs) == 1 + assert result == ["999"] @pytest.mark.parametrize("id_count", [5, 15]) @@ -321,6 +388,9 @@ def test_batch_create_hubspot_objects_chunked_error(mocker, status, expected_err "user", chunk, ) + # A 429 from the batch call is re-raised immediately for backoff, + # without attempting individual retries + mock_sync_contact.assert_not_called() else: result = tasks.batch_create_hubspot_objects_chunked( HubspotObjectType.CONTACTS.value, @@ -328,8 +398,151 @@ def test_batch_create_hubspot_objects_chunked_error(mocker, status, expected_err chunk, ) assert result == [] - for item in chunk: - mock_sync_contact.assert_any_call(item) + for item in chunk: + mock_sync_contact.assert_any_call(item) + + +def test_batch_create_hubspot_objects_chunked_duplicate_hubspot_id(mocker): + """ + A hubspot_id already mapped to a different object should be skipped (logged) + rather than raising the unique (hubspot_id, content_type) IntegrityError. + """ + existing_user, new_user = UserFactory.create_batch(2) + content_type = ContentType.objects.get_for_model(existing_user) + shared_hubspot_id = "420637415" + HubspotObject.objects.create( + content_type=content_type, + object_id=existing_user.id, + hubspot_id=shared_hubspot_id, + ) + mock_hubspot_api = mocker.patch("hubspot_xpro.tasks.HubspotApi") + mock_hubspot_api.return_value.crm.objects.batch_api.create.return_value = ( + mocker.Mock( + results=[ + SimplePublicObjectFactory( + id=shared_hubspot_id, + properties={"email": new_user.email}, + ) + ] + ) + ) + + # Should not raise, and should skip the conflicting mapping + result = tasks.batch_create_hubspot_objects_chunked( + HubspotObjectType.CONTACTS.value, "user", [new_user.id] + ) + + assert result == [] + mappings = HubspotObject.objects.filter( + content_type=content_type, hubspot_id=shared_hubspot_id + ) + assert mappings.count() == 1 + assert mappings.first().object_id == existing_user.id + + +def test_batch_create_hubspot_objects_chunked_reraises_unexpected_integrity_error( + mocker, +): + """ + An IntegrityError that is not a known (hubspot_id/object_id, content_type) + mapping conflict should propagate rather than being silently skipped. + """ + user = UserFactory.create() + mock_hubspot_api = mocker.patch("hubspot_xpro.tasks.HubspotApi") + mock_hubspot_api.return_value.crm.objects.batch_api.create.return_value = ( + mocker.Mock( + results=[ + SimplePublicObjectFactory(id="123", properties={"email": user.email}) + ] + ) + ) + mocker.patch( + "hubspot_xpro.tasks.HubspotObject.objects.update_or_create", + side_effect=IntegrityError("unexpected db problem"), + ) + + with pytest.raises(IntegrityError): + tasks.batch_create_hubspot_objects_chunked( + HubspotObjectType.CONTACTS.value, "user", [user.id] + ) + + +def test_batch_create_hubspot_objects_chunked_skips_unserializable(mocker): + """An object that can't be serialized is skipped while the rest still sync""" + users = sorted(UserFactory.create_batch(3), key=lambda contact: contact.id) + object_ids = [contact.id for contact in users] + good_message = SimplePublicObjectFactory() + + def fake_message(obj_id): + if obj_id == object_ids[1]: + raise ValueError("bad data") + return good_message + + mocker.patch.dict( + "hubspot_xpro.tasks.api.MODEL_FUNCTION_MAPPING", {"user": fake_message} + ) + mock_hubspot_api = mocker.patch("hubspot_xpro.tasks.HubspotApi") + mock_hubspot_api.return_value.crm.objects.batch_api.create.return_value = ( + mocker.Mock(results=[]) + ) + tasks.batch_create_hubspot_objects_chunked( + HubspotObjectType.CONTACTS.value, "user", object_ids + ) + mock_hubspot_api.return_value.crm.objects.batch_api.create.assert_called_once() + sent_inputs = ( + mock_hubspot_api.return_value.crm.objects.batch_api.create.call_args.args[ + 1 + ].inputs + ) + assert len(sent_inputs) == 2 + + +def test_batch_create_hubspot_objects_chunked_skips_bad_result(mocker): + """A result missing unique_app_id is skipped without aborting the batch""" + product = ProductFactory.create() + content_type = ContentType.objects.get_for_model(Product) + mocker.patch.dict( + "hubspot_xpro.tasks.api.MODEL_FUNCTION_MAPPING", + {"product": lambda _obj_id: SimplePublicObjectFactory()}, + ) + bad_result = SimplePublicObjectFactory(id="222", properties={}) + good_result = SimplePublicObjectFactory( + id="111", properties={"unique_app_id": f"xpro-{product.id}"} + ) + mock_hubspot_api = mocker.patch("hubspot_xpro.tasks.HubspotApi") + mock_hubspot_api.return_value.crm.objects.batch_api.create.return_value = ( + mocker.Mock(results=[bad_result, good_result]) + ) + result = tasks.batch_create_hubspot_objects_chunked( + HubspotObjectType.PRODUCTS.value, "product", [product.id] + ) + assert result == ["111"] + assert HubspotObject.objects.filter( + content_type=content_type, hubspot_id="111", object_id=product.id + ).exists() + assert not HubspotObject.objects.filter(hubspot_id="222").exists() + + +def test_batch_create_hubspot_objects_chunked_skips_unresolved_user(mocker): + """A contact result whose email matches no active user is skipped""" + user = UserFactory.create() + mocker.patch.dict( + "hubspot_xpro.tasks.api.MODEL_FUNCTION_MAPPING", + {"user": lambda _obj_id: SimplePublicObjectFactory()}, + ) + unknown_result = SimplePublicObjectFactory( + id="333", properties={"email": "nobody@example.com"} + ) + good_result = SimplePublicObjectFactory(id="444", properties={"email": user.email}) + mock_hubspot_api = mocker.patch("hubspot_xpro.tasks.HubspotApi") + mock_hubspot_api.return_value.crm.objects.batch_api.create.return_value = ( + mocker.Mock(results=[unknown_result, good_result]) + ) + result = tasks.batch_create_hubspot_objects_chunked( + HubspotObjectType.CONTACTS.value, "user", [user.id] + ) + assert result == ["444"] + assert not HubspotObject.objects.filter(hubspot_id="333").exists() def test_batch_upsert_associations(settings, mocker, mocked_celery): @@ -413,6 +626,53 @@ def test_batch_upsert_associations_chunked(mocker): ) +def test_batch_upsert_associations_chunked_skips_bad_order(mocker): + """A bad-data order is skipped while gathering associations; others still process""" + mock_hubspot_api = mocker.patch("hubspot_xpro.tasks.HubspotApi") + orders = sorted(OrderFactory.create_batch(3), key=lambda order: order.id) + for order in orders: + LineFactory.create( + order=order, + product_version=ProductVersionFactory.create(price=Decimal("200.00")), + ) + bad_order = orders[1] + + def fake_id(obj): + if isinstance(obj, Order) and obj.id == bad_order.id: + raise ValueError("bad data") + return f"hs-{type(obj).__name__}-{obj.id}" + + mocker.patch("hubspot_xpro.tasks.get_hubspot_id_for_object", side_effect=fake_id) + result = tasks.batch_upsert_associations_chunked([order.id for order in orders]) + assert result == [order.id for order in orders] + assert mock_hubspot_api.return_value.crm.associations.batch_api.create.called + + +@pytest.mark.parametrize( + "status, expected_error", # noqa: PT006 + [[400, None], [429, TooManyRequestsException]], # noqa: PT007 +) +def test_batch_upsert_associations_chunked_api_error(mocker, status, expected_error): + """A non-429 association API error is swallowed; a 429 still propagates""" + mock_hubspot_api = mocker.patch("hubspot_xpro.tasks.HubspotApi") + mock_hubspot_api.return_value.crm.associations.batch_api.create.side_effect = ( + ApiException(status=status) + ) + orders = OrderFactory.create_batch(2) + for order in orders: + LineFactory.create( + order=order, + product_version=ProductVersionFactory.create(price=Decimal("200.00")), + ) + mocker.patch("hubspot_xpro.tasks.get_hubspot_id_for_object", return_value="hs-1") + order_ids = [order.id for order in orders] + if expected_error: + with pytest.raises(expected_error): + tasks.batch_upsert_associations_chunked(order_ids) + else: + assert tasks.batch_upsert_associations_chunked(order_ids) == order_ids + + @pytest.mark.parametrize( "func_name,args,kwargs,result", # noqa: PT006 [ @@ -429,7 +689,7 @@ def test_task_obj_lock(func_name, args, kwargs, result): def test_sync_failed_contacts(mocker): - """sync_failed_contacts should try to sync each contact and return a list of failed contact ids""" + """sync_failed_contacts should collect failed ids and not abort on non-429 errors""" user_ids = sorted(user.id for user in UserFactory.create_batch(4)) mock_sync = mocker.patch( "hubspot_xpro.tasks.api.sync_contact_with_hubspot", @@ -437,7 +697,7 @@ def test_sync_failed_contacts(mocker): mocker.Mock(), ApiException(status=500, reason="err"), mocker.Mock(), - ApiException(status=429, reason="tmr"), + ValueError("unexpected"), ], ) result = tasks.sync_failed_contacts(user_ids) @@ -445,6 +705,22 @@ def test_sync_failed_contacts(mocker): assert result == [user_ids[1], user_ids[3]] +def test_sync_failed_contacts_reraises_429(mocker): + """A 429 should propagate so Celery retry/backoff can apply, not be swallowed""" + user_ids = sorted(user.id for user in UserFactory.create_batch(3)) + mocker.patch( + "hubspot_xpro.tasks.api.sync_contact_with_hubspot", + side_effect=[ + mocker.Mock(), + ApiException(status=429, reason="tmr"), + mocker.Mock(), + ], + ) + with pytest.raises(ApiException) as exc_info: + tasks.sync_failed_contacts(user_ids) + assert exc_info.value.status == 429 + + @pytest.mark.parametrize("for_contacts", [True, False]) @pytest.mark.parametrize("has_errors", [True, False]) def test_handle_failed_batch_chunk(mocker, for_contacts, has_errors): From 4972c28c17c7c3aa8ca7b9eb8d4d8c1d289bf8f0 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Fri, 7 Aug 2026 11:13:56 -0400 Subject: [PATCH 04/16] Harden GitHub Actions supply-chain security with zizmor and delay dep updates (#4036) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Harden GitHub Actions supply-chain security with zizmor and delay dep updates Adds static analysis of GitHub Actions workflows so risky patterns (unpinned actions, excessive permissions, script injection, etc.) are caught before they land, both in CI and locally: - New CI workflow (actions-static-analysis.yml) runs zizmor against .github/workflows/ on any change to workflow files, mirroring the pattern already in use in mitodl/mitxonline. - Adds zizmor as a pre-commit hook so the same checks run locally before a workflow change is even pushed. Also sets exclude-newer = "7d" in [tool.uv] so `uv lock` only resolves packages that have been published for at least 7 days. This gives the ecosystem a window to catch and yank newly-published malicious or broken releases before mitxpro's lockfile can pick them up. Ran `uv lock` to regenerate uv.lock with the new exclude-newer-span; no package versions shifted as a result. * Exempt in-house MIT ODL packages from exclude-newer cool-down The 7-day exclude-newer added for zizmor security hardening is meant to guard against newly-published third-party malicious/vulnerable packages, not to slow down consumption of our own actively-developed releases. Add [tool.uv.exclude-newer-package] overrides of "0d" for every package we own in ol-concourse, django-aqueduct, open-edx-plugins, and ol-django, so uv always resolves these to their latest published version while the 7-day delay still applies to everything else. * Fix zizmor high-severity findings: pin unpinned actions, add uv version guard zizmor's auto-fix (zizmor --fix=all) refuses to guess a pin for non-version refs, so these two high-severity unpinned-uses findings needed manual pins to the commit each ref currently resolves to: - actions/setup-node@v2-beta in ci.yml (flagged by Sentry review on the actions-static-analysis workflow) -> pinned to the same commit as the released v2.1.3 tag. - mitodl/ol-github-workflows/.../add-to-ol-hq.yaml@main in new-issues.yml -> pinned to current main HEAD (repo has no version tags). Also added [tool.uv] required-version = ">=0.9.17" to pyproject.toml, the uv release that introduced relative-duration exclude-newer support already in use here, and aligned the pre-commit zizmor hook args (--min-severity=high --min-confidence=medium) with the CI check's threshold so local runs match CI. Deliberately left production.yml and release-candiate.yml untouched: zizmor --fix=all's incidental persist-credentials/comment fixes there are low-severity and not required to pass CI's high-severity gate, and leaving the files alone avoids any risk of touching the dangerous- triggers findings on their workflow_run trigger, which is under separate, active security review to determine actual exploitability. Remaining findings (including those) are tracked in a follow-up issue. * Harden deploy workflow permissions Add a minimal 'permissions: contents: read' block to production.yml and release-candiate.yml — neither declared one despite the repo defaulting GITHUB_TOKEN to write scope. Verified akhileshns/heroku-deploy takes no GitHub-token input and never calls the GitHub API (auth is purely via HEROKU_API_KEY/HEROKU_EMAIL), so contents: read (for actions/checkout) is sufficient. Matches the same fix applied to micromasters and ocw-studio. * fix(ci): drop redundant pull_request trigger from zizmor workflow Both push and pull_request fired on every PR commit for the same path-scoped check, running zizmor twice per push. push alone still covers PR branch commits. Co-Authored-By: Claude Sonnet 5 * fix(ci): add back pull_request trigger for zizmor workflow, scope push to master Bot reviewers (Copilot, Sentry) correctly flagged that a push-only trigger misses fork-based PRs and can't act as a required merge-gate status check. Scoping push to the default branch avoids the original double-run problem (push firing on every commit to a same-repo PR branch, redundant with pull_request) while restoring PR-gate coverage. Co-Authored-By: Claude Sonnet 5 * chore: trim exclude-newer-package allowlist to this repo's actual dependencies The allowlist exempting in-house MIT ODL packages from the 7-day uv dependency cool-down was copy-pasted org-wide, unpruned. Reviewers on two separate PRs independently flagged the same thing: most of the ~44 entries (mostly Open edX plugins) aren't dependencies of this repo at all. Trimmed to the intersection with this repo's own dependency closure (uv.lock's locked package set, or pyproject.toml's declared deps where no lockfile exists). Co-Authored-By: Claude Sonnet 5 * chore(ci): remove Heroku deployment workflows We no longer deploy mitxpro to Heroku. This also clears the two pre-existing dangerous-triggers findings zizmor flagged on these files' workflow_run usage (tracked in mitxpro#4037). Co-Authored-By: Claude Sonnet 5 * fix(deps): regenerate uv.lock to match the trimmed exclude-newer-package allowlist The previous commit edited pyproject.toml's allowlist without regenerating uv.lock, so 'uv sync --locked' correctly rejected the mismatch in CI. Co-Authored-By: Claude Sonnet 5 * fix(ci): lower zizmor gate to min-severity=medium, fix resulting findings - ci.yml: add workflow-level `permissions: contents: read` — both jobs (python-tests, javascript-tests) only checkout, build, test, and upload coverage to CodeCov, no elevated scope needed - new-issues.yml: - replace `secrets: inherit` on the `add-to-hq` reusable-workflow call with an explicit `secrets:` map naming only `OL_HQ_PROJECT_SECRET` (confirmed via the callee's `workflow_call.secrets` block in mitodl/ol-github-workflows — it's the only secret the workflow declares, used as the `github-token` for actions/add-to-project) - add `permissions: contents: read` on the `add-to-hq` job; the reusable workflow authenticates with the passed PAT secret, not GITHUB_TOKEN, so no elevated scope is needed - actions-static-analysis.yml: lower zizmor gate `min-severity` from `high` to `medium` (min-confidence stays `medium`) - .pre-commit-config.yaml: matching zizmor hook arg `--min-severity=medium` - .secrets.baseline: regenerated by detect-secrets after the `secrets: inherit` line (previously flagged as a false-positive "Secret Keyword") was removed Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- .github/workflows/actions-static-analysis.yml | 34 +++++++++++++++++++ .github/workflows/ci.yml | 14 ++++++-- .github/workflows/new-issues.yml | 7 ++-- .github/workflows/production.yml | 34 ------------------- .github/workflows/release-candiate.yml | 34 ------------------- .pre-commit-config.yaml | 5 +++ .secrets.baseline | 11 +----- pyproject.toml | 12 +++++++ uv.lock | 14 ++++++++ 9 files changed, 82 insertions(+), 83 deletions(-) create mode 100644 .github/workflows/actions-static-analysis.yml delete mode 100644 .github/workflows/production.yml delete mode 100644 .github/workflows/release-candiate.yml diff --git a/.github/workflows/actions-static-analysis.yml b/.github/workflows/actions-static-analysis.yml new file mode 100644 index 000000000..9c9900c5a --- /dev/null +++ b/.github/workflows/actions-static-analysis.yml @@ -0,0 +1,34 @@ +name: GitHub Actions Static Analysis + +on: + push: + branches: + - "master" + paths: + - ".github/workflows/**" + pull_request: + paths: + - ".github/workflows/**" + +permissions: {} + +jobs: + zizmor: + name: Run zizmor + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run zizmor 🌈 + uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 + with: + inputs: ".github/workflows/" + min-severity: medium + min-confidence: medium + advanced-security: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77b5f11a5..1767105ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,9 @@ name: CI on: [push] + +permissions: + contents: read + jobs: python-tests: runs-on: ubuntu-22.04 @@ -26,7 +30,9 @@ jobs: - 6379:6379 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Apt update run: sudo apt-get update -y @@ -100,10 +106,12 @@ jobs: javascript-tests: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Setup NodeJS - uses: actions/setup-node@v2-beta + uses: actions/setup-node@27082cecf3ff7a1742dbd5e12605f0cb59dce2d9 # v2.1.3 with: node-version: 24.14.0 diff --git a/.github/workflows/new-issues.yml b/.github/workflows/new-issues.yml index 75d3fe65a..f890e0858 100644 --- a/.github/workflows/new-issues.yml +++ b/.github/workflows/new-issues.yml @@ -8,5 +8,8 @@ on: jobs: add-to-hq: - uses: mitodl/ol-github-workflows/.github/workflows/add-to-ol-hq.yaml@main - secrets: inherit + permissions: + contents: read + uses: mitodl/ol-github-workflows/.github/workflows/add-to-ol-hq.yaml@edd566dec1edd920d35ab1fb60b589cddc44afad # main + secrets: + OL_HQ_PROJECT_SECRET: ${{ secrets.OL_HQ_PROJECT_SECRET }} diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml deleted file mode 100644 index 03a875d76..000000000 --- a/.github/workflows/production.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Production Deploy - -on: - workflow_run: - workflows: [CI] - types: [completed] - branches: [release] - workflow_dispatch: # manual trigger - -jobs: - # runs if CI workflow was successful OR if this was manually triggered - on-success: - runs-on: ubuntu-22.04 - if: > - github.event_name == 'workflow_dispatch' || - github.event.workflow_run.conclusion == 'success' - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - with: - ref: release - - uses: akhileshns/heroku-deploy@e3eb99d45a8e2ec5dca08735e089607befa4bf28 - with: - heroku_api_key: ${{ secrets.HEROKU_API_KEY }} - heroku_app_name: "xpro-production" - heroku_email: ${{ secrets.HEROKU_EMAIL }} - branch: release - # runs ONLY on a failure of the CI workflow - on-failure: - runs-on: ubuntu-22.04 - if: > - github.event_name == 'workflow_dispatch' || - github.event.workflow_run.conclusion == 'failure' - steps: - - run: echo 'The triggering workflow failed' diff --git a/.github/workflows/release-candiate.yml b/.github/workflows/release-candiate.yml deleted file mode 100644 index 7d64fba05..000000000 --- a/.github/workflows/release-candiate.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Release Candidate Deploy - -on: - workflow_run: - workflows: [CI] - types: [completed] - branches: [release-candidate] - workflow_dispatch: # manual trigger - -jobs: - # runs if CI workflow was successful OR if this was manually triggered - on-success: - runs-on: ubuntu-22.04 - if: > - github.event_name == 'workflow_dispatch' || - github.event.workflow_run.conclusion == 'success' - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - with: - ref: release-candidate - - uses: akhileshns/heroku-deploy@e3eb99d45a8e2ec5dca08735e089607befa4bf28 - with: - heroku_api_key: ${{ secrets.HEROKU_API_KEY }} - heroku_app_name: "xpro-rc" - heroku_email: ${{ secrets.HEROKU_EMAIL }} - branch: release-candidate - # runs ONLY on a failure of the CI workflow - on-failure: - runs-on: ubuntu-22.04 - if: > - github.event_name == 'workflow_dispatch' || - github.event.workflow_run.conclusion == 'failure' - steps: - - run: echo 'The triggering workflow failed' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dee9a6dd0..88b9634de 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -108,6 +108,11 @@ repos: - id: actionlint name: actionlint description: Runs actionlint to lint GitHub Actions workflow files + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: v1.29.0 + hooks: + - id: zizmor + args: [--no-progress, --min-severity=medium, --min-confidence=medium] - repo: local hooks: - id: drf-serializer-orm-check diff --git a/.secrets.baseline b/.secrets.baseline index 1e565c0e2..113d72c47 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -124,15 +124,6 @@ } ], "results": { - ".github/workflows/new-issues.yml": [ - { - "type": "Secret Keyword", - "filename": ".github/workflows/new-issues.yml", - "hashed_secret": "3e26d6750975d678acb8fa35a0f69237881576b0", - "is_verified": false, - "line_number": 12 - } - ], "authentication/utils.py": [ { "type": "Secret Keyword", @@ -245,5 +236,5 @@ } ] }, - "generated_at": "2026-04-13T13:24:33Z" + "generated_at": "2026-08-05T19:40:29Z" } diff --git a/pyproject.toml b/pyproject.toml index 0eac6956d..e6354ab2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,18 @@ dev = [ [tool.uv] package = false no-binary-package = ["lxml", "xmlsec"] +exclude-newer = "7d" +required-version = ">=0.9.17" + +[tool.uv.exclude-newer-package] +mitol-django-authentication = "0d" +mitol-django-common = "0d" +mitol-django-hubspot-api = "0d" +mitol-django-mail = "0d" +mitol-django-oauth-toolkit-extensions = "0d" +mitol-django-observability = "0d" +mitol-django-olposthog = "0d" +mitol-drf-lint = "0d" [tool.ruff] target-version = "py313" diff --git a/uv.lock b/uv.lock index bde1225a2..3c5604c29 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,20 @@ resolution-markers = [ "python_full_version < '3.14'", ] +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +mitol-django-hubspot-api = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } +mitol-drf-lint = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } +mitol-django-observability = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } +mitol-django-authentication = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } +mitol-django-olposthog = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } +mitol-django-common = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } +mitol-django-mail = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } +mitol-django-oauth-toolkit-extensions = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } + [[package]] name = "amqp" version = "5.3.1" From a6c831ba904b5e90b5596e15721917ca8e1d6d22 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:37:58 +0000 Subject: [PATCH 05/16] chore(deps): update actions/checkout action to v7.0.1 (#4045) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1767105ef..3ca9f9b87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: - 6379:6379 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -106,7 +106,7 @@ jobs: javascript-tests: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false From c146777877ed56114a00e2fc1c05066d3a7798ad Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:39:48 +0500 Subject: [PATCH 06/16] [pre-commit.ci] pre-commit autoupdate (#4044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.16.1 → v0.16.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.16.1...v0.16.2) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 88b9634de..16c73efb0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,7 +51,7 @@ repos: - --exclude-files - "_test.js$" - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.16.1" + rev: "v0.16.2" hooks: - id: ruff-format - id: ruff From cac137bcd85827e18c579b97dbd9d099b71b0407 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:12:05 +0500 Subject: [PATCH 07/16] fix(deps): update dependency boto3 to v1.43.62 (#4046) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e6354ab2e..f7f767e2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "Pillow==10.4.0", "PyNaCl==1.6.2", "beautifulsoup4==4.15.0", - "boto3==1.43.52", + "boto3==1.43.62", "celery==5.6.3", "celery-redbeat==2.3.3", "dj-database-url==3.1.2", diff --git a/uv.lock b/uv.lock index 3c5604c29..9b3681ddd 100644 --- a/uv.lock +++ b/uv.lock @@ -174,30 +174,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.43.52" +version = "1.43.62" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/93/5f98e422ced57a359e3b7827ab79b359d67456abb9c5840b0c5eb7b90e6b/boto3-1.43.52.tar.gz", hash = "sha256:3da09a393c050906d407023bee5fb5b5fb333378513af1a1a51159db870b572f", size = 112680, upload-time = "2026-07-20T19:31:28.396Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/c7/f7732c5e1abf7270a6bbbce47338d25ea66a30df658cffd1d17bb5f735fb/boto3-1.43.62.tar.gz", hash = "sha256:0bf920e0739346e81c7310b685a3f783bf1fcc62ce7d5c7016508fa25c0d261f", size = 112668, upload-time = "2026-07-31T19:35:17.257Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/57/31e6f45c3e236273f9f83f047ae579a90c4be8c6dc1b8366d63a6dcd9841/boto3-1.43.52-py3-none-any.whl", hash = "sha256:1af1950c2b2400267c93d971d588367fc73554e02b4f435a50a5e930a7c93526", size = 140026, upload-time = "2026-07-20T19:31:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f0/5e1a392c817e395b140c18c12a00c0c65c69f8d63da26ad4387aebf2172b/boto3-1.43.62-py3-none-any.whl", hash = "sha256:0bb298e7ffd72b91615df44bf71c417df80a29d844971e5d665b8bd743a4bb35", size = 140025, upload-time = "2026-07-31T19:35:15.347Z" }, ] [[package]] name = "botocore" -version = "1.43.56" +version = "1.43.67" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/cc/7f84a5d3071fe878380e9f610ab36ca87b8cbbc4aa81ba2727f90e1f3ea3/botocore-1.43.56.tar.gz", hash = "sha256:6c01f85f0ff9863076f4c761e74ee3aa96c5ccc1ad09fc1efd62ef8f2d22bf57", size = 15733117, upload-time = "2026-07-24T19:31:38.125Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/1c/3a75deae60e36bd0ee5c27d040384756b2ea1c90bd7c8c9658335a18b4f5/botocore-1.43.67.tar.gz", hash = "sha256:6fe5cfa0c8676ba809efe505b618ec00f30d1af2d014bf316a7aa4ee86accb20", size = 15889514, upload-time = "2026-08-07T19:30:15.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/cd/86fe9e659e9699f62f8dd5ecd8c6725474334b23cab8aa71d82b5f56f1a4/botocore-1.43.56-py3-none-any.whl", hash = "sha256:aafc741f1b10f6fd63253eaf6ea029680c1ff436d87e1b8969d62aefa0c76976", size = 15418773, upload-time = "2026-07-24T19:31:34.758Z" }, + { url = "https://files.pythonhosted.org/packages/21/be/38af8e96f3d200c9d34eab8e9f69a4468388ed6030ea04ff012974e5af5a/botocore-1.43.67-py3-none-any.whl", hash = "sha256:48ab8e9fac26fbc2a700d57010251003e6a5f731cf74d8540fb796bc8f3fc0ef", size = 15575924, upload-time = "2026-08-07T19:30:12.116Z" }, ] [[package]] @@ -2025,7 +2025,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "beautifulsoup4", specifier = "==4.15.0" }, - { name = "boto3", specifier = "==1.43.52" }, + { name = "boto3", specifier = "==1.43.62" }, { name = "celery", specifier = "==5.6.3" }, { name = "celery-redbeat", specifier = "==2.3.3" }, { name = "dj-database-url", specifier = "==3.1.2" }, From 9a5375f9583ce479d9f613cae8c0e0bd5a78eabb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:34:39 +0500 Subject: [PATCH 08/16] chore(deps): update dependency posthog-js to v1.417.0 (#4051) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 60 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/yarn.lock b/yarn.lock index c772f88de..4fab749a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2573,29 +2573,29 @@ __metadata: languageName: node linkType: hard -"@posthog/browser-common@npm:^0.3.1": - version: 0.3.1 - resolution: "@posthog/browser-common@npm:0.3.1" +"@posthog/browser-common@npm:^0.5.0": + version: 0.5.0 + resolution: "@posthog/browser-common@npm:0.5.0" dependencies: - "@posthog/core": ^1.46.0 - "@posthog/types": ^1.399.0 - checksum: 276eaaa29e2e422bc5e5d0688619831823522f0d97799850b2d7f5c59b8b8606a165639a0e16725d5e6d79dcbe3d5f4a2ccc3c013c43c32e9a1b437ffa033ccd + "@posthog/core": ^1.47.0 + "@posthog/types": ^1.402.2 + checksum: d877016b362f710e029d79c8b146b1d148e328f10a5d789909969553f1722f1f7ab8a0eee37a13dbdf1b4309c282ae3015db2d5060bde938b60200653d572dc6 languageName: node linkType: hard -"@posthog/core@npm:^1.46.0, @posthog/core@npm:^1.46.3": - version: 1.46.4 - resolution: "@posthog/core@npm:1.46.4" +"@posthog/core@npm:^1.47.0, @posthog/core@npm:^1.48.1": + version: 1.48.1 + resolution: "@posthog/core@npm:1.48.1" dependencies: - "@posthog/types": ^1.400.0 - checksum: 4bc99bbf32ed13ce42bb84362313ec3ff39fa49a28dd03ff50997b5f81662abe89acfbb21aff83f0ec4dd9660dd85fd4b270ab839021be91394307095c81c2fa + "@posthog/types": ^1.404.1 + checksum: 9f269aa90099c6bc32d3d9218d3c342e7236e51f40140e94e1754283adbc86f7f6154244853e7b4563ff166f7d480497d750ee1129b34f3895575d4ef787f09b languageName: node linkType: hard -"@posthog/types@npm:^1.399.0, @posthog/types@npm:^1.400.0": - version: 1.400.0 - resolution: "@posthog/types@npm:1.400.0" - checksum: 7eb224fb26027a5e08e1fbf78ebe30b40cf65db47c0c9e8617d966f95221dc2defea78c4adf9abbf30196e6dca784636c903c87e04e3077f7ebbaf3b519307f2 +"@posthog/types@npm:^1.402.2, @posthog/types@npm:^1.404.1": + version: 1.404.1 + resolution: "@posthog/types@npm:1.404.1" + checksum: 7952be33ecf6614973ab7d87364f8c33bc34f83605ac8ac939ef88e80c4a6786d8076182a93d86fca19110f431ba45c90dcaa1547f707eadac5f07ba716a9917 languageName: node linkType: hard @@ -5320,15 +5320,15 @@ __metadata: languageName: node linkType: hard -"dompurify@npm:^3.3.2": - version: 3.4.5 - resolution: "dompurify@npm:3.4.5" +"dompurify@npm:^3.4.13": + version: 3.4.13 + resolution: "dompurify@npm:3.4.13" dependencies: "@types/trusted-types": ^2.0.7 dependenciesMeta: "@types/trusted-types": optional: true - checksum: b9a00d27e80f3639406a9500992d3765149b161dd99a1b8fa3d87be0962333de3af3d1c9aab9a0317ced8c254d101f0398742fe854b4d5eb5a67c59959950de7 + checksum: e4d3c08e453521098557118ffaaa6e2ab42f9222edd57ab592b69946d76127973640df13a9c260da3cf89b6c525ccaa58bf398d0b3aff01c3b7bc3fa589b7260 languageName: node linkType: hard @@ -10621,19 +10621,20 @@ __metadata: linkType: hard "posthog-js@npm:^1.171.0": - version: 1.410.1 - resolution: "posthog-js@npm:1.410.1" + version: 1.417.1 + resolution: "posthog-js@npm:1.417.1" dependencies: - "@posthog/browser-common": ^0.3.1 - "@posthog/core": ^1.46.3 - "@posthog/types": ^1.400.0 + "@posthog/browser-common": ^0.5.0 + "@posthog/core": ^1.48.1 + "@posthog/types": ^1.404.1 core-js: ^3.49.0 - dompurify: ^3.3.2 + dompurify: ^3.4.13 fflate: ^0.4.8 preact: ^10.29.3 query-selector-shadow-dom: ^1.0.1 web-vitals: ^5.3.0 - checksum: 8fbbee0c60fa8eeb8df872c06868502742437358ecf550b35b2b89bb225e52963b47c7484cb71c62be34d1bb6294237b973c1c4e942cc7f38107cf101fc098b1 + web-vitals-soft-navs: "npm:web-vitals@6.0.0" + checksum: 9f2e05667bc518fcd919207803c64f7c7e93362ece1076a1e87174988c1fdf41e9892947144d437197c527f8a58aef580d6fc1e2559a57cf5b53e93d6b7b9fb1 languageName: node linkType: hard @@ -13822,6 +13823,13 @@ __metadata: languageName: node linkType: hard +"web-vitals-soft-navs@npm:web-vitals@6.0.0": + version: 6.0.0 + resolution: "web-vitals@npm:6.0.0" + checksum: 2605159b9286488fcd375167dc737d31dcb10c2f7924ec1be93a329f4cefe7588504b7c6422164d15aa1560687f76b29148965c6ed052d2011ef87a5fdf2dbe0 + languageName: node + linkType: hard + "web-vitals@npm:^5.3.0": version: 5.3.0 resolution: "web-vitals@npm:5.3.0" From 1dad35c953f1061db56ea26efd4b180bc672ffe3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:48:14 +0500 Subject: [PATCH 09/16] chore(deps): update dependency mocha to v11.8.0 (#4050) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 4fab749a5..897526cbd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9628,8 +9628,8 @@ __metadata: linkType: hard "mocha@npm:^11.0.0": - version: 11.7.6 - resolution: "mocha@npm:11.7.6" + version: 11.8.0 + resolution: "mocha@npm:11.8.0" dependencies: browser-stdout: ^1.3.1 chokidar: ^4.0.1 @@ -9655,7 +9655,7 @@ __metadata: bin: _mocha: bin/_mocha mocha: bin/mocha.js - checksum: ce797bacc32ba99b5311816eb6c707e81e28ab5d1f20acc27b1c2e5542cfe0d12427fdd06136a89a48b36122b103f3e1b05b40b1cd664979c9514f37133789ab + checksum: b4b662e1e8bc32db9170961eab7fea536b7d0c268adb8667621fb4f288c8596effeffdb5197b3274aa2314f7e5c15920dbd78312d7de9ef17a13224db683dea5 languageName: node linkType: hard From e6f2c073e39c7f813a2deef2102e338fa342e67c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:06:39 +0500 Subject: [PATCH 10/16] chore(deps): update dependency js-yaml to v5.3.0 (#4049) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 897526cbd..02695354a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8490,13 +8490,13 @@ __metadata: linkType: hard "js-yaml@npm:^5.0.0": - version: 5.2.2 - resolution: "js-yaml@npm:5.2.2" + version: 5.3.0 + resolution: "js-yaml@npm:5.3.0" dependencies: argparse: ^2.0.1 bin: js-yaml: bin/js-yaml.mjs - checksum: 651ad7009f48a3dad5084d58e56d52891ebbd5a942fee05735fa173b5f22c1df5af774a58f30f2ead2d87868b5438ae907f7d072ac0e425e6d04b970d0314885 + checksum: 7b2f946dab72b05ac0fa4b40daa6230044df06fa7a8a8c75944f4e7f644914960fc2f01e7a98d714d986126418cafab1b1b495fbad74f211df49e4818808db55 languageName: node linkType: hard From 6d428ee4d6b1e13dd4ba13759097e5fc81468ca5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:59:38 +0000 Subject: [PATCH 11/16] fix(deps): update dependency boto3 to v1.43.63 (#4053) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f7f767e2d..577204d24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "Pillow==10.4.0", "PyNaCl==1.6.2", "beautifulsoup4==4.15.0", - "boto3==1.43.62", + "boto3==1.43.63", "celery==5.6.3", "celery-redbeat==2.3.3", "dj-database-url==3.1.2", diff --git a/uv.lock b/uv.lock index 9b3681ddd..4238b8a44 100644 --- a/uv.lock +++ b/uv.lock @@ -174,16 +174,16 @@ wheels = [ [[package]] name = "boto3" -version = "1.43.62" +version = "1.43.63" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/c7/f7732c5e1abf7270a6bbbce47338d25ea66a30df658cffd1d17bb5f735fb/boto3-1.43.62.tar.gz", hash = "sha256:0bf920e0739346e81c7310b685a3f783bf1fcc62ce7d5c7016508fa25c0d261f", size = 112668, upload-time = "2026-07-31T19:35:17.257Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/b7/3fdd53534170ef7d99d1707071e57ea0aeb0dec84ed0206d31e8c78f54d7/boto3-1.43.63.tar.gz", hash = "sha256:647c0f0b59710ce12a49323382bb5ece1b4e02199c736d19d555330dca7e947f", size = 112658, upload-time = "2026-08-03T19:55:05.178Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/f0/5e1a392c817e395b140c18c12a00c0c65c69f8d63da26ad4387aebf2172b/boto3-1.43.62-py3-none-any.whl", hash = "sha256:0bb298e7ffd72b91615df44bf71c417df80a29d844971e5d665b8bd743a4bb35", size = 140025, upload-time = "2026-07-31T19:35:15.347Z" }, + { url = "https://files.pythonhosted.org/packages/a6/57/a91420a14ef26fe52fc0706b5af21c00b2e4ff1c57b0c4272370c9b80399/boto3-1.43.63-py3-none-any.whl", hash = "sha256:859a8d1c50505a5cefb8629790dd34602ddb85bfd7ea5d34a362ab1793513636", size = 140024, upload-time = "2026-08-03T19:55:03.4Z" }, ] [[package]] @@ -2025,7 +2025,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "beautifulsoup4", specifier = "==4.15.0" }, - { name = "boto3", specifier = "==1.43.62" }, + { name = "boto3", specifier = "==1.43.63" }, { name = "celery", specifier = "==5.6.3" }, { name = "celery-redbeat", specifier = "==2.3.3" }, { name = "dj-database-url", specifier = "==3.1.2" }, From 8d7e6b63badd01b66e918be87d40f0d6c2507218 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 07:06:10 +0000 Subject: [PATCH 12/16] fix(deps): update dependency pygsheets to v2.0.6 (#3757) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 577204d24..35281d95f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ dependencies = [ "pdftotext>=3.0.0,<4", "psycopg2==2.9.12", "pycountry==26.2.16", - "pygsheets==2.0.2", + "pygsheets==2.0.6", "redis==7.4.1", "requests>=2.31.0,<3", "sentry-sdk>=2.0.0,<3", From f5a42fa1465599fa0beff39a7e2d660a7c918402 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 07:07:40 +0000 Subject: [PATCH 13/16] fix(deps): update dependency django-anymail to v15.1 (#4056) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 35281d95f..98f921d99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "celery-redbeat==2.3.3", "dj-database-url==3.1.2", "django==5.2.16", - "django-anymail[mailgun]==15.0", + "django-anymail[mailgun]==15.1", "django-filter>=24", "django-health-check[celery,redis]>=4.0.0", "django-hijack==3.7.8", diff --git a/uv.lock b/uv.lock index 4238b8a44..1fbdc1f03 100644 --- a/uv.lock +++ b/uv.lock @@ -661,7 +661,7 @@ wheels = [ [[package]] name = "django-anymail" -version = "15.0" +version = "15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, @@ -669,9 +669,9 @@ dependencies = [ { name = "requests" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/43/f0aadb31f2c58afcd9f001f4291998cbd6d289898167e79d908506fc6faf/django_anymail-15.0.tar.gz", hash = "sha256:23d8ab6589afe8cc1ae7665c26879814ad192f4c3ed837a2a1868b0a056869e0", size = 106985, upload-time = "2026-04-18T20:44:19.237Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/69/c4e075a39e69efc928ad63e2cad9b720c6414d9faf9b90d2342207a8feb8/django_anymail-15.1.tar.gz", hash = "sha256:299fb12a21ac4101e9706c2ade461d18deacbb6bc3750cfa6ca08aeea1fc9442", size = 107700, upload-time = "2026-07-30T22:30:27.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/d1/daae99ec3b30886010a499975880ec20c32c622bee6b92c226b715e42f0c/django_anymail-15.0-py3-none-any.whl", hash = "sha256:64d33dd1084bfc8e4e12245f56629be40aa0b0498fc7fc7544d87b9b2048be1e", size = 147229, upload-time = "2026-04-18T20:44:17.323Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bb/3fbac63312c87620986ac00c7b27d11ef1a7c07ffb5718ca5bd5c60e2f5f/django_anymail-15.1-py3-none-any.whl", hash = "sha256:cb482b0e2b0aea665c83d49e3f13bb0673210aa0de285b1748a3b5e6277fc826", size = 147352, upload-time = "2026-07-30T22:30:26.359Z" }, ] [[package]] @@ -2030,7 +2030,7 @@ requires-dist = [ { name = "celery-redbeat", specifier = "==2.3.3" }, { name = "dj-database-url", specifier = "==3.1.2" }, { name = "django", specifier = "==5.2.16" }, - { name = "django-anymail", extras = ["mailgun"], specifier = "==15.0" }, + { name = "django-anymail", extras = ["mailgun"], specifier = "==15.1" }, { name = "django-filter", specifier = ">=24" }, { name = "django-health-check", extras = ["celery", "redis"], specifier = ">=4.0.0" }, { name = "django-hijack", specifier = "==3.7.8" }, From 56dfa20bec15a64a0fbec22d5d368b2d571abbf3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:03:05 +0500 Subject: [PATCH 14/16] chore(deps): update dependency hls.js to v1.7.0 (#4048) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 02695354a..5357e5e47 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7410,9 +7410,9 @@ __metadata: linkType: hard "hls.js@npm:^1.0.0": - version: 1.6.16 - resolution: "hls.js@npm:1.6.16" - checksum: 4e11ffffd241cb0a853b6465a49d781abb7fa6c2294745edb0c5a978d297824decc9e8c7f8bae842694da2390ce3c6369b82a28b421164b37d864f16fda87446 + version: 1.7.0 + resolution: "hls.js@npm:1.7.0" + checksum: e2e8bdf002a038ffa3f33ffbfbc953c7e3952cf6d5a9eecef4467945c07bdc552f63497b47e06feb1c262d80722eea7505e96b5496bb4fc5674d0858fffa7ca1 languageName: node linkType: hard From c8c0d1ab6dc509be2c02f590978301e7fdff7bf8 Mon Sep 17 00:00:00 2001 From: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:01:22 +0500 Subject: [PATCH 15/16] fix: revert pygsheets 2.0.6 (#4059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(deps): revert pygsheets to 2.0.2 to unblock CI This reverts commit 8d7e6b63badd01b66e918be87d40f0d6c2507218. pygsheets 2.0.6 depends on google-auth-oauthlib>=0.7.1, which requires google-auth>=2.14.0, but this project pins google-auth==1.35.0. That resolution is unsatisfiable, so uv.lock could never be updated to match the new pin, and CI's `uv sync --locked` has failed on every commit since 8d7e6b63 landed. Hold pygsheets at 2.0.2 until google-auth is upgraded to 2.x separately. Co-Authored-By: Claude Opus 5 (1M context) * chore(renovate): require review for google auth stack updates pygsheets, google-auth and google-auth-oauthlib are version-coupled — bumping any one of them alone is unsatisfiable against the current pins, which is how an unresolvable pygsheets bump automerged with a red python-tests and broke `uv sync --locked` on master. Add them to the existing review-required list so a bump in this cluster cannot automerge without a human looking at it. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- pyproject.toml | 2 +- renovate.json | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 98f921d99..80f046d3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ dependencies = [ "pdftotext>=3.0.0,<4", "psycopg2==2.9.12", "pycountry==26.2.16", - "pygsheets==2.0.6", + "pygsheets==2.0.2", "redis==7.4.1", "requests>=2.31.0,<3", "sentry-sdk>=2.0.0,<3", diff --git a/renovate.json b/renovate.json index 115c36646..53d94cb7c 100644 --- a/renovate.json +++ b/renovate.json @@ -3,7 +3,12 @@ "extends": ["local>mitodl/.github:renovate-config"], "packageRules": [ { - "matchPackageNames": ["sass"], + "matchPackageNames": [ + "sass", + "pygsheets", + "google-auth", + "google-auth-oauthlib" + ], "automerge": false } ] From ff7bea2639b813385ce6562ee0881d34260ce9e4 Mon Sep 17 00:00:00 2001 From: Doof Date: Wed, 19 Aug 2026 15:19:29 +0000 Subject: [PATCH 16/16] Release 0.198.0 --- RELEASE.rst | 19 +++++++++++++++++++ mitxpro/settings.py | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index bd9924931..64ac17e31 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,25 @@ Release Notes ============= +Version 0.198.0 +--------------- + +- fix: revert pygsheets 2.0.6 (#4059) +- chore(deps): update dependency hls.js to v1.7.0 (#4048) +- fix(deps): update dependency django-anymail to v15.1 (#4056) +- fix(deps): update dependency pygsheets to v2.0.6 (#3757) +- fix(deps): update dependency boto3 to v1.43.63 (#4053) +- chore(deps): update dependency js-yaml to v5.3.0 (#4049) +- chore(deps): update dependency mocha to v11.8.0 (#4050) +- chore(deps): update dependency posthog-js to v1.417.0 (#4051) +- fix(deps): update dependency boto3 to v1.43.62 (#4046) +- [pre-commit.ci] pre-commit autoupdate (#4044) +- chore(deps): update actions/checkout action to v7.0.1 (#4045) +- Harden GitHub Actions supply-chain security with zizmor and delay dep updates (#4036) +- fix: make sync_db_to_hubspot resilient to bad data (#4019) +- fix(deps): update dependency mitol-django-authentication to v2026 (#4040) +- fix(deps): update dependency mitol-django-mail to v2026 (#4041) + Version 0.197.2 (Released August 18, 2026) --------------- diff --git a/mitxpro/settings.py b/mitxpro/settings.py index 75e6081f8..14e7585ef 100644 --- a/mitxpro/settings.py +++ b/mitxpro/settings.py @@ -26,7 +26,7 @@ from mitxpro.celery_utils import OffsettingSchedule from mitxpro.sentry import init_sentry -VERSION = "0.197.2" +VERSION = "0.198.0" env.reset()