Skip to content

Release 0.78.1 - #3873

Merged
odlbot merged 21 commits into
releasefrom
release-candidate
Sep 1, 2026
Merged

odlbot merged 21 commits into
releasefrom
release-candidate

Conversation

@odlbot

@odlbot odlbot commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Shankar Ambady

Zaman Afzal

renovate[bot]

Tobias Macey

Chris Chudzicki

Danielle Frappier

Matt Bertrand

Ahtesham Quraish

renovate Bot and others added 21 commits August 27, 2026 14:02
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
* adding staleness penalty for resource vector search

* update tests
The Stay Updated button was gated on three things: the
NEXT_PUBLIC_STAY_UPDATED_HUBSPOT_FORM_ID env var, the CMS page's
show_stay_updated flag, and an enrollment-mode check requiring every
enrollment mode to be "verified".

The mode check was doing work the CMS flag already covers, and on courses
it iterated course.courseruns unfiltered — including runs with
live: false / is_enrollable: false that appear nowhere else on the page
(the session selector filters to is_enrollable). One retired audit run
therefore suppressed the button for the whole course.

Example: course-v1:UAI_SOURCE+UAI.MLTL.1 has show_stay_updated: true and
one enrollable verified-only run, but a second run (id 2465, live: false,
is_enrollable: false) carries an "audit" mode, so the button never
rendered.

Visibility is now just: can it be shown (env var, checked in
ProductPageTemplate) and should it be shown (page.show_stay_updated).

Tests for each page collapse to those branches; the shared
PROGRAM_HIDE_STAY_UPDATED_CASES only served the removed mode
permutations. The env-var tests now set show_stay_updated explicitly —
the page factory randomizes it, so without the mode clause they would
have been coin-flips.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Add overridable mutation error-toast infrastructure (Option 2)

Global MutationCache.onError shows a top-center error toast for every
browser mutation failure, so a failed mutation is never silent. Call
sites override via typed `meta` (declared in api/mutation-meta):
- `showErrorToast: false` to opt out (e.g. renders its own inline error)
- `errorMessage` / `getErrorMessage(error, variables)` for custom copy

`api` stays UI-free (declares typed `meta` only); the toast lives in
`main`. Copy resolution is defensive: a throwing getErrorMessage or
empty copy falls back to the generic message inside onError, so a bad
override can never make the failure silent again. The store holds one
toast; a newer error replaces it (never stacks), and it persists until
dismissed via smoot Alert's own close control.

The convention is documented in .github/instructions/frontend.instructions.md.

Not yet shippable: sites that already render inline alerts will
double-fire until opted out (follow-up migration).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Opt out surfaced inline-error sites from the global error toast

The global MutationCache.onError toast (previous commit) fires on every
browser mutation failure. Sites that already render their own co-located
inline error would therefore double-fire (inline alert + toast).

Give each shared api/ mutation hook an optional `meta` param
(MutationHookOptions, exported from api/mutation-meta) forwarded to
useMutation, and have every consumer that renders an inline error pass
`meta: SILENCE_ERROR_TOAST`. Opt-out lives at the consumer, never baked
into the shared hook, because several hooks (enrollment, baskets) are
used in both a surfaced and a silent context. UpgradeBanner opts out
only when its onUpgradeFailure callback is wired — without it the
banner has no error surface of its own, so the toast must stay.

Also mount <Toaster/> in renderWithProviders and reset the toast store
per test, so a missing opt-out at an inline-error site surfaces as a
double-role="alert" test failure rather than only showing up in manual
QA, and add end-to-end coverage (mutationErrorToast.test.tsx) that the
default toast fires and SILENCE_ERROR_TOAST suppresses it — so removing
the MutationCache wiring can no longer pass green.

Adopting the genuinely-silent sites (Start button, destroy dialogs,
listItemMove rollback) with tailored copy is a separate follow-up; those
already toast via the global default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Strengthen consumer error-path tests around the toast opt-outs

Convert text-based error assertions to singular role queries
(findByRole("alert")) in the CourseEnrollmentDialog and JustInTimeDialog
tests, so a stray global toast — a missing opt-out, or a shared hook
that stops forwarding `meta` — fails these tests as a second alert.

Add failing-mutation coverage for all three DashboardDialogs (email
settings, unenroll, unenroll program), whose opt-outs and inline error
rendering were previously untested. Writing those tests surfaced that
each dialog awaited mutateAsync inside formik's onSubmit: the rejection
escaped onSubmit (formik logs an unhandled-error warning), and the
post-await isError guards were unreachable on the error path. Switch to
mutate(..., { onSuccess }) — same success/error behavior (inline alert
via isError, busy state via isPending), no escaping rejection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fail any test that leaves the global error toast unacknowledged

The double-alert test net only tripped where an error-path test happened
to assert via a singular role="alert" query. Make it structural: an
afterEach in setupJest fails any test that ends with the global error
toast still showing, and its message teaches the fix — opt the component
out with `meta: SILENCE_ERROR_TOAST` if it renders its own inline error,
or acknowledge the toast with the new `expectErrorToast(message)` helper
if the toast is the intended error surface. Every mutation failure a
test drives now requires a deliberate decision about its error surface.

The full main suite passes with zero acknowledgment churn — no existing
test drives a toasting failure it doesn't already account for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add tailored enroll error copy as errorMessage PoC

The dashboard enroll flow (useEnrollmentHandler) becomes the first adopter
of meta.errorMessage: a failed enroll toasts course- or program-specific
copy instead of the generic message, demonstrating the per-site override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Fall back to errorMessage when getErrorMessage throws or returns blank

resolveErrorMessage collapsed the dynamic and static tiers into one
nullish-coalescing expression, so a getErrorMessage that threw or
returned blank copy skipped a defined errorMessage and jumped straight
to the generic fallback. Evaluate the tiers separately so each falls
through to the next when it yields no message.

Flagged in review: #3837 (comment)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…#3825)

* ci: add a ci-gate job so one required check covers the whole suite

GitHub's required status checks take an exact list of context names. There are
no wildcards and no "all checks must pass" option, so requiring these six jobs
from the ruleset in mitodl/ol-infrastructure means restating all six names
there, and that list then goes stale in two ways.

A renamed job keeps being required under its old name, which GitHub will never
report again, so every PR waits forever on it. mitxonline hit exactly this when
its python-tests job became a 4-way matrix and the checks turned into
`python-tests (1)`..`(4)`.

A newly added job is not required until somebody remembers to go and add it, so
new CI silently cannot block a merge.

`ci-gate` fails if any job it needs ends in anything but success or skipped.
Requiring it instead keeps the list of what must pass in the same file as the
jobs it names, so adding or renaming a job is one edit in one repo.

openapi-diff is deliberately not covered: it lives in its own workflow, and it
runs `oasdiff breaking --fail-on ERR`, so it goes red on intentional breaking
API changes. It was red at merge on 8 of the last 40 merged PRs here, release
PRs included, so gating merges on it would mean bypassing the ruleset routinely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QygRcd6WF4BmUi1C5cez4d

* ci: wire up err-ignore allowlist for openapi-diff breaking check

needs can't reach openapi-diff (separate workflow, pull_request trigger),
so it can't join ci-gate directly. Give it the allowlist mechanism oasdiff
already supports so intentional breaking changes stop needing a routine
ruleset bypass, then require it alongside ci-gate in ol-infrastructure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wnKLHVJ2vP4G6mPN9AgFr

* ci: move oasdiff-err-ignore.txt out of openapi/specs/

openapi_spec_check.sh diffs openapi/specs/ against freshly generated
specs and fails on any extra file -- confirmed in the python-tests CI
run for dcd62cf. Move the ignore list to openapi/ instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wnKLHVJ2vP4G6mPN9AgFr

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ync (#3808)

* feat(cohort-1): MicroMasters/MITx Online certificate warehouse-pull sync

Second PR in the replacement stack for closed mit-learn#3566, stacked
on the StarRocks warehouse-pull machinery (#3807). Replaces Hightouch
as the writer of external.programcertificate — currently broken since
~2025-06-01 — with a warehouse-pull Celery task reading
integrations__learn__program_certificates (mitodl/ol-data-platform#2591).

Resolves mitodl/hq#12954.

- profiles/etl.py: transform_program_certificate + upsert_program_certificate.
  Deliberately never prunes, even on full_refresh — a certificate is a
  durable achievement record, not a catalog resource that should
  self-heal by deleting rows a pull didn't see (a transient upstream
  join gap must never read as "certificate revoked").
- profiles/tasks.py: SyncProgramCertificatesTask(BaseWarehouseETLTask),
  registered via app.register_task per the base class's documented
  requirement (the @app.task(base=...) decorator doesn't work for it).
- main/settings_celery.py: one beat entry, gated on STARROCKS_HOST
  (daily). Doesn't participate in the WAREHOUSE_ETL_CUTOVER_SOURCES
  mechanism — Hightouch ran externally, not as an MIT Learn Celery
  task, so there's no legacy beat entry to retire.
- main/settings.py: removed "programcertificate" from EXTERNAL_MODELS.
  That setting made main.routers.ExternalSchemaRouter reject every
  Django-side write to this model, correct back when only Hightouch
  wrote to it — this task is now the intended writer, so the guard no
  longer applies. Flagging explicitly: this removes a safety check
  (main/routers_test.py's test_external_tables_are_readonly, which
  asserted the exact behavior just removed, is deleted alongside it).

Field set was checked directly against profiles.ProgramCertificate
(not assumed from memory) — the model stores 11 more columns than
first planned (user_edxorg_username, user_mitxonline_username, name/
demographic/address fields), all now covered by both the dbt model and
this transform.

* fix: require STARROCKS_USER too before scheduling the certificate sync

The beat entry gate checked only STARROCKS_HOST, but
_connect_starrocks() (learning_resources.lib.warehouse) also requires
STARROCKS_USER — set HOST without USER and the task gets scheduled but
fails every run with ImproperlyConfigured.

Addresses sentry[bot] feedback on #3808.

* Address review feedback: manage the model, isolate bad rows

shanbady: with the warehouse-pull cutover Django owns writes to
external.programcertificate, so it should own the schema too. Flipping
managed generates a state-only migration — migration 0015 already created
the table, so there is no DDL and no data risk.

shanbady: one malformed certificate row no longer aborts the batch. Skipped
rows self-heal because the daily beat schedule runs full_refresh over the
whole view. An all-rows-failed batch still raises: that is a broken database
rather than a bad row, and returning normally would advance an incremental
run's watermark past a window nothing was written for.

Copilot: the mapping test claimed to cover every field but left the three
address fields null and unasserted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFhTucXavYwkjv6o4qXZ8r

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
* Submit and link each resource under one URL, from learn_url

Four places derived a resource's URL on Learn independently — the resources,
video, podcast and products sitemaps — and the card href and drawer canonical
derived it a fifth and sixth time. They did not agree, and the resources sitemap
submitted a drawer URL for every resource on top of whatever dedicated page the
per-type sitemaps submitted. A video was therefore advertised twice, and because
the drawer canonicalized to itself, both URLs claimed to be the original and
crawlers had no way to consolidate ranking signal onto the dedicated page.

Read `learn_url` instead, which the backend computes as the resource's own page
where it has one and its search drawer where it does not:

- The card href points at it. `pushUrl` is untouched, so a click still opens the
  drawer in place and nothing changes for users; only what a crawler follows
  changes.
- The drawer canonicalizes to it. For a video or podcast episode that is now the
  dedicated page rather than the drawer itself, which is what actually
  consolidates the signal — excluding a URL from the sitemap only stops us
  advertising it, and says nothing about one that is linked or shared.
- The resources sitemap emits it for every published resource, so the video,
  podcast and products sitemaps are gone: they existed to emit dedicated-page
  URLs the frontend had to build itself.

Collapsing the sitemaps also settles which parent scopes an episode's URL. The
podcast sitemap mapped over every parent podcast, so an episode with two parents
would have been submitted twice; one URL per resource cannot.

The count query now asks for `limit: 1`. It reads only `count`, so it was
fetching a thousand rows and discarding them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fall back to the drawer URL when learn_url is missing

learn_url is non-nullable and never blank, but a frontend deployed ahead of the
backend sees the field absent, and both call sites degrade badly on a falsy
value rather than merely pointing somewhere less ideal:

- the drawer emitted no canonical tag at all, which is worse than the
  self-canonical it replaces
- the card dropped its href, and BaseLearningResourceCard's `linkTarget` drops
  `pushUrl` along with a falsy href — so the title would be neither a link nor
  clickable, breaking the drawer for that card

Fall back to the drawer URL in both, and assert it: reverting either fix fails
the new cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Describe what the sitemap and canonical do, not what changed

Both comments narrated the edit, which only parses while holding the diff.
State the durable facts instead: the sitemap needs no per-type branching and a
resource with several parents appears once under its canonical parent; the
drawer hands its ranking signal to the resource's dedicated page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Trim the drawer canonical comment to the invariant

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Narrow the falsified-learn_url cast to the one field

`as never` on the whole object drops type checking for every other prop, so a
later change to the resource shape would not surface here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Share the resource's page rather than a drawer over search

A shared link now lands on the page that owns the content where one exists, so
sharing stops seeding drawer URLs for crawlers to find. Slightly user-visible:
the recipient sees the dedicated page instead of the search page with a drawer
over it.

The drawer reads the detail endpoint, which always carries learn_url, so the
fallback here is only for a frontend deployed ahead of the backend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Read learn_url directly, without a fallback

learn_url is non-nullable, so the drawer canonical, the card href and the share
URL can read it as given. Drops the `||` branches, the comments explaining them,
and the absent/blank cases that covered them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Ahtesham Quraish <ahtesham.quraish@arbisoft.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
* Update dependency drf-spectacular to >=0.30,<0.31

* update spec

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Anastasia Beglova <abeglova@mit.edu>
* Use the green token instead of a hardcoded hex on product pages

Two product-page spots hardcoded #008000 because the `green` token was
#00AD00, which fails WCAG AA as text on a white card. smoot-design now
ships #008000 as `green`, so both can use the token:

- `CertificateTrackCard`: the approved financial-aid link. Drops the
  APPROVED_GREEN constant and reads the token off the styled callback's
  theme, along with the comment paragraph that justified the raw hex.
- `ProgramSavingsBlock`: the "Save $X" text.

Part of mitodl/hq#13051.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Update @mitodl/smoot-design to 6.33.4

6.33.4 is the first release carrying the green token change from
mitodl/smoot-design#255 (#00AD00 -> #008000), which the product-page
checkmarks read via theme.custom.colors.green.

Note this also carries everything else between 6.33.1 and 6.33.4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: report the real status from handle_error instead of 405

handle_error is registered as handler400/handler403/handler404 but was
decorated with a bare @api_view(), which DRF defaults to GET-only. Any
non-GET request that raised PermissionDenied, BadRequest or Http404 was
therefore answered `405 Method Not Allowed, Allow: GET, OPTIONS` with
error_type MethodNotAllowed, and the actual reason never reached the
caller.

That is what made the Canvas ContentFile webhook failure unreadable for
ten days: the serializer was rejecting course_readable_id=null and
raising Django's BadRequest, but every one of those ~5,500 failures
surfaced as a 405, which reads as a missing route or an edge
misconfiguration rather than a payload problem.

error_type is set explicitly per branch rather than derived from the
exception class, because an unmatched URL raises Resolver404 and would
otherwise have silently changed the published error_type from "Http404".

test_ovs_video_webhook_invalid_signature asserted 405 for an invalid
signature, encoding the bug as expected behaviour; it now asserts 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91SVaUM8EWo2kYA6FL3tB

* fix: include TRACE in handle_error and correct a stale test comment

TRACE is in Django's View.http_method_names, so omitting it from the
@api_view list left one method still answering 405 and masking the real
status. Verified before the change: TRACE to an unmatched /api/v1/ route
returned 405 MethodNotAllowed; it now returns 404. Added to the method
regression test.

The comment on test_change_form_save_of_deleted_published_is_forbidden
said handle_error "renders every client error as a 404", which was the
behaviour this branch removes. The test still passes at 404, but for a
different reason: /admin/ has no version namespace, so DRF's
NamespaceVersioning raises NotFound from initial() before the handler
body runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91SVaUM8EWo2kYA6FL3tB

* fix: split handle_error into separate Django handler400/403/404 functions

handle_40x only fires once a DRF view has declined the exception or the
error happened outside DRF entirely, so @api_view's request pipeline
(versioning, content negotiation) never applied to the response it wraps
- it could only add spurious side effects to a request DRF had already
passed on. NamespaceVersioning under /admin/ is one such side effect:
it raised NotFound from initial() before handle_error's body ran,
turning a PermissionDenied into a 404 instead of the real 403.

Per review feedback from ChristopherChudzicki on #3791.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* feat(b2b): render the analytics dashboard scoped to a contract

The dashboard was org-scoped at every layer while the MITx Online manager
dashboard it mirrors is contract-scoped, so a manager arriving from a
contract page landed on org-wide numbers.

Adds a contract-nested route, client and query layer alongside the org ones
-- the org endpoints are staying, so nothing currently deployed changes
shape. AnalyticsContent takes an optional contractSlug and picks the scope
once; every section below that choice is untouched, since both scopes return
the identical envelope and row shapes.

The slug in the route is not the id the API filters on. It resolves through
the org's contracts list to MITx Online's contract id, the same lookup
ContractAdminPage already does. A slug matching no contract of the org reads
as "unavailable" rather than issuing a request with `undefined` in the path,
which the API would answer 403 -- indistinguishable from a real denial.

Three type corrections fall out of touching these files, all of them
pre-existing drift against the API rather than consequences of this change:

- contract_pk, courserun_pk and program_pk are typed `number` here but have
  been `str` in the API since ol-analytics-api#29. They are md5 surrogates,
  not integers. CoursePerformanceTable's grouping Map follows.
- MonthlyEngagementTrend's three activity totals became nullable in
  ol-analytics-api#33 and are still typed non-null; its five cohort columns
  and ContentEngagementDepth's two were missing entirely.

Depends on ol-analytics-api#34 and, beneath it, ol-data-platform#2559 --
neither route nor column exists until both land and the MVs rebuild.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC8Vb9vZiGMPeHd97Fy4Gj

* fix(b2b): point "Manage seats" at the contract being viewed

On a contract-scoped analytics page the header link was built from
org.contracts[0], so a manager looking at the second contract's numbers was
sent to the first contract's seat admin. It now prefers the resolved
contract and falls back to the first only on the org-wide page, where there
is no current contract.

Also strengthens the negative test added with the contract routes: a bare
`waitFor` around `not.toHaveBeenCalled` passes on its first tick, before the
manager-org lookup resolves, so it would have held even if a contract
request were issued a moment later. It now awaits the settled unavailable
state before asserting.

Adds the regression test the link bug did not have.

Both caught by Copilot in review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC8Vb9vZiGMPeHd97Fy4Gj

* fix(b2b): narrow analytics query-key erasure, split contract-not-found messaging

Per review on #3773: the `as unknown as SectionQueries` cast on the memoized
query factories erased row types along with the query key, so a section
wired to the wrong factory would compile silently. `eraseKey` now widens only
the key; `eraseContractTrendRow` isolates the one section (engagement trend)
where the contract-scoped factory legitimately returns a wider row type.

Also splits the "contract slug doesn't resolve" case out of the org-level
"analytics not available" notice -- the org is fine here, only the link is
stale, so point the manager at organization-wide analytics instead of asking
them to contact support about nothing they can fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2QDxf9qiStMtPdVzyepkj

* Fix Manage seats fallback on unresolved contract link, name the contract in the header

The unresolved-contract branch fell back to org.contracts[0] for the
"Manage seats" href, so a stale or mistyped contract link showed a
"Manage seats" button pointing at a different contract right above the
"could not be found" notice. Only fall back on the org-wide route now.

Also label the contract-scoped page with the contract's name so a
manager on a multi-contract org can tell which one they're looking at.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145upmVEJ8jLGq2n2qe7e9Z

* Scope the content-engagement section to the contract being viewed

Rebasing onto main pulled in #3806's content-engagement panel, which
queried analyticsOrganizationQueries.contentEngagement unconditionally
-- so a manager on the contract-scoped page this PR adds would see the
whole org's content-engagement numbers instead of their contract's,
unlike the other four sections. analyticsContractQueries.contentEngagement
already existed in the client layer, just unwired; this wires it in the
same eraseKey/scoped pattern as the rest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145upmVEJ8jLGq2n2qe7e9Z

* Use AppPageProps on the contract analytics page

main added a lint rule (hq#12925) after this branch forked, requiring
AppPageProps over PageProps so a route's searchParams type stays
checked against the CDN cache-key whitelist. This route doesn't read
searchParams, but the rule applies to every page.tsx regardless.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145upmVEJ8jLGq2n2qe7e9Z

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
* feat: per-product hubspot_form_id gates the Stay Updated form

A course/program page shows its "Stay Updated" button only when the CMS
page provides a non-empty hubspot_form_id, and opens that page's own
HubSpot form. hubspot_form_id becomes the sole toggle, replacing the old
show_stay_updated boolean and the global NEXT_PUBLIC_STAY_UPDATED_HUBSPOT_FORM_ID
env var (removed along with its config helper, validateEnv entry, and
env/*.env declarations).

Consumes the real generated mitxonline client that types hubspot_form_id
(interim branch tarball; to be swapped for the published npm version).

* build(deps): pin mitxonline-api-axios to published 2026.8.31

The branch pinned a GitHub tarball URL, which would put a dependency on
another repo's commit into main. Published 2026.8.31 carries the
hubspot_form_id field this branch needs and no longer declares
show_stay_updated.

---------

Co-authored-by: Carey P Gumaer <gumaerc@mit.edu>
@odlbot
odlbot requested a review from a team as a code owner August 31, 2026 19:29
@github-actions

Copy link
Copy Markdown

OpenAPI Changes

497 changes: 366 error, 0 warning, 131 info

View full changelog

Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

@odlbot
odlbot merged commit 653b15c into release Sep 1, 2026
18 of 19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants