Skip to content

build: prepare for Renovate and add a dependency cooling-off period - #67

Draft
feanil wants to merge 10 commits into
mainfrom
feanil/renovate-prework
Draft

build: prepare for Renovate and add a dependency cooling-off period#67
feanil wants to merge 10 commits into
mainfrom
feanil/renovate-prework

Conversation

@feanil

@feanil feanil commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Groundwork for moving dependency management to Renovate. The Renovate config
itself is a small follow-up PR; this one is everything that has to be true
first, plus adjacent breakage found along the way.

Why

The repo has two dependency-update mechanisms and neither works. The weekly
Upgrade Python Requirements workflow calls the shared openedx pip-tools
workflow, which runs make upgrade from the repository root — there is no root
Makefile, and its ADD_PATHS="requirements" would never stage uv.lock. It
has failed every run since the move to uv. Renovate was intended for the Node
side but was never configured. Dependabot covers github-actions only, so
neither package-lock.json has ever been updated.

The cooling-off period

The headline change is a 7-day minimum release age on every dependency, so a
freshly published — possibly hijacked — release cannot be pulled in
immediately.

It is configured in the package managers, not in Renovate, because
Renovate cannot enforce a cooldown on a lockfile refresh: it delegates that to
uv or npm and never enumerates the individual releases, so
minimumReleaseAge does not apply to it. Configuring the resolvers instead
covers transitive dependencies as well as direct ones, and means make upgrade
or npm install on a laptop behaves exactly like the bot will.

Where Setting
backend-plugin-sample/pyproject.toml [tool.uv] exclude-newer = "7 days"
frontend-plugin-sample/.npmrc, brand-sample/.npmrc min-release-age=7
renovate.json5 (next PR) minimumReleaseAge: '7 days'

These three have to stay in agreement. If the resolvers are stricter than
Renovate, Renovate proposes versions they refuse and leaves empty branches
behind.

Verified that this does not disturb the install path: uv lock is idempotent
afterwards, uv lock --check and uv sync --locked still pass, and npm ci
plus npm run build are unaffected in both npm packages.

Why >=0 on every dependency

Renovate drops any PEP 621 dependency with no version specifier at all
(skipReason: "unspecified-version") before it ever queries PyPI — a correct
lockedVersion in uv.lock does not help, because the manifest is what it
reads as the reference. Almost every entry here was a bare name, so Renovate
would have produced no update PRs for them.

>=0 is the PEP 440 equivalent of the "*" ranges already used in
frontend-plugin-sample/package.json; PEP 440 has no bare wildcard, as
Django==* is a parse error. It keeps these deliberately unconstrained — the
platform pins them itself, and a plugin that narrows them causes resolution
conflicts inside edx-platform — while stating that intent explicitly instead
of leaving it implied by omission.

Real floors were considered and rejected: derived from uv.lock they would be
guesses, since the lock records the newest resolvable version rather than the
oldest supported one. Django>=5.2 would block installation on a platform
running Django 4.2, quite possibly wrongly.

The one genuine floor is edx-lint>=6.2, which is verifiable rather than
guessed — see below.

One required status check instead of six

Branch protection matches required checks by name, which made the required list
both fragile and incomplete.

Fragile, because naming the backend jobs individually means spelling out every
matrix leg — tests (ubuntu-latest, 3.12, django52) and four more — so adding
or retiring a Python or Django version quietly breaks protection until someone
updates the repository settings.

Incomplete, because the frontend and tutor jobs were both called build.
Two check runs with one name cannot be told apart in a required list, so
neither was ever required. A pull request touching nothing but frontend files
could merge on the strength of checks that could not have been affected by it.
That gap matters much more once dependency updates start merging themselves,
because GitHub's auto-merge waits for required checks and nothing else.

So ci.yml now runs on pull requests, invokes each per-package workflow, and
ends in a gate job that succeeds only if all of them did. gate is the only
name branch protection has to know, and it never changes. release.yml calls
ci.yml too, so a release runs exactly the checks that were required to merge.

The aggregation is five lines of shell rather than a third-party action. This
job is the one thing standing between a pull request and main, so it is the
last place worth adding a dependency — particularly in a change whose purpose
is to reduce how much unreviewed third-party code we pull in.

Things this turned up

make upgrade was broken. It calls edx_lint write_uv_constraints, which
does not exist before edx-lint 6.2.0 — with edx-lint pinned at 6.0.0 the target
printed the usage text instead of regenerating anything. Raising the floor
surfaced two files edx-lint owns that had drifted: constraint-dependencies was
missing three of edx-lint's global constraints, and pylintrc was still stamped
Generated by edx-lint version: 5.6.0, predating the plugin's requirement that
pii_terms be configured — under 6.2.0 pylint aborted outright.

make check-constraints now guards that, wired into the quality toxenv. The
loop closes itself: edx-lint is a managed dependency, so the pull request that
bumps it is the one that goes red if its global constraints moved along with it.
This is the alternative to a scheduled regeneration job, which Renovate cannot
do (the hosted app has no postUpgradeTasks) and which would anyway produce
pull requests that do not trigger CI when using the default GITHUB_TOKEN.

brand-sample had no CI, and it is publishing. Its dist/ is committed and
served to real MFEs straight from GitHub via
cdn.jsdelivr.net/gh/openedx/sample-plugin@main, so it is a published artifact
rather than a build leftover. Bumping package-lock.json does not regenerate
it, so a dependency update could quietly leave the CSS people download out of
step with the sources here. The new job rebuilds and diffs dist/. Its .nvmrc
also moves from Node 20 to 24 — Node 20 is EOL and bundles npm 10.8.2, which
silently ignores min-release-age, so brand-sample would have got no benefit
from the cooldown at all.

Two runtime dependencies were undeclared. apps.py imports
edx_django_utils and pipeline.py imports crum, but neither was in
[project].dependencies. They resolved anyway because the test-base group
pulls in edx-django-utils, which brings django-crum transitively — so the
omission was invisible in development and in CI, and would only surface as an
ImportError for someone installing the published wheel.

.readthedocs.yaml is dormant, not broken. It did ask for the deleted
requirements/doc.txt, but Read the Docs reads its config from the repository
root and this file is a directory down, so nothing reads it — and no project
exists (both plausible subdomains 404). Fixed in place using Read the Docs'
native method: uv support so it resolves from uv.lock, with a note
recording that it is dormant and that its paths are relative to
backend-plugin-sample/. Docs are built with fail_on_warning on every pull
request by tox -e docs regardless.

Merging this

The five tests (...) contexts stop reporting once backend-ci.yml no longer
runs directly on pull_request, so this PR cannot satisfy the current required
checks:

  1. Set the required checks on main to just openedx/cla.
  2. Merge.
  3. Set them to openedx/cla, gate, and commitlint / commitlint.

commitlint.yml is deliberately left alone — it is one of the openedx standard
workflow files kept in sync by org automation, so folding it into ci.yml would
be reverted. Its context name does not depend on a matrix, so requiring it
separately costs nothing.

Merging cuts a patch release (fix: and build: commits are in patch_tags).

Not included

  • frontend-plugin-sample/package.json is untouched. "*" is already a valid
    semver range, so Renovate sees those four dependencies today — "*" is the
    npm form of >=0. Changing them would mean inventing constraints.
  • docs/openedx_plugin_sample.rst is stale — admin.py was added without
    regenerating the API docs, and tox -e docs refreshes it in place. Left out
    because docs: would bump this from a patch to a minor release. Worth a
    follow-up.
  • The two test toxenvs segfault locally, identically on an unmodified checkout
    of main, so it is a local environment problem rather than anything here.
    CI on main is green and pytest run directly passes all 23 tests.

feanil and others added 10 commits September 2, 2026 11:23
`apps.py` imports `edx_django_utils` and `pipeline.py` imports `crum`, but
neither was in `[project].dependencies`. They resolved anyway because the
`test-base` dependency group pulls in `edx-django-utils`, which brings
`django-crum` transitively -- so the omission was invisible in development
and in CI, and would only surface as an ImportError for someone installing
the published wheel into an environment without the test groups.

The uv.lock change is metadata only: both packages were already resolved,
so only this project's own declared dependency list gains the entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Renovate skips any PEP 621 dependency that has no version specifier at all
(`skipReason: "unspecified-version"`), dropping it before it ever queries
PyPI -- a correct `lockedVersion` in uv.lock does not help, because the
manifest is what Renovate reads as the reference. Since almost every entry
here was a bare name, Renovate would have produced no update PRs for them.

`>=0` is the PEP 440 equivalent of the "*" ranges already used in
frontend-plugin-sample/package.json; PEP 440 has no bare wildcard, as
`Django==*` is a parse error. It keeps these deliberately unconstrained --
the Open edX platform pins them itself, and a plugin that narrows them
causes resolution conflicts when installed into edx-platform -- while
stating that intent explicitly instead of leaving it implied by omission.

Real floors were considered and rejected: derived from uv.lock they would
be guesses, since the lock records the newest resolvable version rather
than the oldest supported one. `Django>=5.2` would block installation on a
platform running Django 4.2, quite possibly wrongly.

The one genuine floor is `edx-lint>=6.0`, which is verifiable rather than
guessed: `edx_lint write_uv_constraints` does not exist before 6.0. The
`test` and `django60` Django pins are untouched -- they define the test
matrix.

Every uv.lock change is a metadata-only rewrite of a `{ name = "x" }` entry
to `{ name = "x", specifier = ">=0" }`. No resolution moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wait seven days before resolving to any newly published release. A hijacked
release is usually yanked within a few days, so for a repo where nothing is
urgent this is cheap insurance against installing a compromised version in
the window before anyone notices.

Set in the package managers rather than in Renovate, because Renovate cannot
enforce a cooldown on a lockfile refresh: it delegates that to uv or npm and
never enumerates the individual releases, so `minimumReleaseAge` does not
apply to it. Configuring the resolvers instead covers transitive
dependencies as well as direct ones, and means `make upgrade` or
`npm install` on a laptop behaves exactly like the bot.

Two .npmrc files rather than one at the repo root: npm reads the project
config only from the directory holding package.json and does not walk up.

Verified: `uv lock` is idempotent afterwards, `uv lock --check` and
`uv sync --locked` still pass, and `npm ci` plus `npm run build` are
unaffected in both npm packages. uv records the window in uv.lock as
`[options] exclude-newer-span` and re-evaluates it per resolve; a widening
window cannot invalidate the lock, since exclude-newer only restricts which
candidates are eligible and never forces an upgrade.

Two caveats recorded in the comments. `min-release-age` needs npm >= 11.10.0,
so it does nothing in brand-sample until its .nvmrc moves off Node 20 --
Node 24 bundles npm 11.19.0. And Renovate's own `minimumReleaseAge` must be
kept at the same value, or it will offer versions the resolvers refuse and
leave empty branches behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
brand-sample had no CI at all, which matters more than it looks: its dist/
is committed and served to real MFEs straight from GitHub via
cdn.jsdelivr.net/gh/openedx/sample-plugin@main, so it is a published
artifact rather than a build leftover. Updating package-lock.json does not
regenerate it, so a dependency bump could quietly leave the CSS people
download out of step with the sources here, with nothing to catch it.

The job installs from the lockfile, rebuilds, and diffs dist/ against the
build output. When it fails, the fix is to run `npm run build` in
brand-sample and commit the result.

Also moves .nvmrc from Node 20 to 24. Node 20 is end-of-life, and it bundles
npm 10.8.2, which silently ignores the `min-release-age` cooling-off setting
added in the previous commit -- so brand-sample got no benefit from it until
now. Node 24 bundles npm 11.19.0. Verified that dist/ still builds
byte-identically under Node 24, so this needs no accompanying rebuild.

The workflow is workflow_call only, because the next commit adds a ci.yml
that aggregates every check into one required status check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Branch protection matches required status checks by name, which made the
required list both fragile and incomplete.

Fragile, because naming the backend jobs individually means spelling out
every matrix leg -- `tests (ubuntu-latest, 3.12, django52)` and four more --
so adding or retiring a Python or Django version quietly breaks protection
until someone remembers to update the repository settings.

Incomplete, because the frontend and tutor jobs were both called `build`.
Two check runs with one name cannot be told apart in a required list, so
neither was ever required. Only the five backend contexts and openedx/cla
were, which means a pull request touching nothing but frontend files could
merge on the strength of checks that could not have been affected by it.
That gap matters much more once dependency updates start merging
themselves, because GitHub's auto-merge waits for required checks and
nothing else.

So ci.yml now runs on pull requests, invokes each per-package workflow, and
ends in a `gate` job that succeeds only if all of them did. `gate` is the
only name branch protection has to know, and it never changes. The
per-package workflows become workflow_call only, so they run once per pull
request rather than twice.

release.yml calls ci.yml instead of the three workflows separately, so a
release runs exactly the checks that were required to merge, and picks up
brand-ci -- and anything added later -- without a change there.

`gate` runs `if: always()`, because a skipped required check never reports
at all and would block the pull request forever rather than failing it.

The aggregation is five lines of shell rather than a third-party action.
This job is the one thing standing between a pull request and main, so it
is the last place worth adding a dependency -- particularly in a change
whose purpose is to reduce how much unreviewed third-party code we pull in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`make upgrade` calls `edx_lint write_uv_constraints`, and that subcommand
does not exist before edx-lint 6.2.0 -- so with edx-lint pinned at 6.0.0 the
target has been failing, printing the usage text instead of regenerating
anything. That is also why the previous commit's `edx-lint>=6.0` floor was
wrong; the real floor is 6.2.

Raising it surfaced two files edx-lint owns that had drifted:

[tool.uv].constraint-dependencies was missing three of edx-lint's global
constraints -- social-auth-app-django<6.0.0, social-auth-core<5.0.0 and
pip<26.2.1. Nothing had regenerated it since those were added upstream,
which is precisely the failure the next commit puts a check around.

pylintrc was still stamped "Generated by edx-lint version: 5.6.0", predating
the pii_annotation_check plugin's requirement that `pii_terms` be
configured. Under 6.2.0 pylint aborted with "The 'pii_terms' setting must be
configured", taking models.py down with an astroid-error. Regenerated with
`edx_lint write pylintrc`, which adds the [PII] section and enables
pii-invalid-no-pii-annotation. The file says DO NOT EDIT, so this is
entirely tool output plus the local pylintrc_tweaks.

`tox -e quality`, `-e docs` and `-e pii_check` all pass afterwards. The two
test envs segfault locally, but they do so identically on an unmodified
checkout of main, so that is a local environment problem rather than
anything from this change -- CI on main is green, and `pytest` run directly
passes all 23 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
[tool.uv].constraint-dependencies is generated by edx-lint from its global
common_constraints.txt plus this repo's [tool.edx_lint].uv_constraints, and
nothing regenerated it automatically -- the previous commit found it three
constraints behind upstream.

The scheduled workflow that used to run `make upgrade` is being removed, and
its replacement cannot take over this job: the Mend-hosted Renovate app has
no postUpgradeTasks, so it cannot run `edx_lint write_uv_constraints` after
an update. Reinstating a cron that opens a pull request is not much better,
because a workflow using the default GITHUB_TOKEN produces pull requests
that do not trigger CI.

So instead of regenerating on a schedule, fail when it drifts. The loop
closes itself: edx-lint is a managed dependency, so the pull request that
bumps it is the one that goes red if edx-lint's global constraints moved
along with it. The drift is reported by the change that caused it, and
automerge is blocked until someone regenerates.

The target diffs against a scratch copy rather than checking `git diff`, so
it reports only this drift and not whatever else is uncommitted in the
working tree, and it restores the file when it fails rather than leaving the
regenerated version behind.

Also fixes `make upgrade` to ask for 'edx-lint>=6.2' rather than a bare
`edx-lint`, so it cannot quietly resolve to a version without the
subcommand it depends on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The requirements/ directory went away when this repo moved to uv.lock, but
several references to it survived:

- tox.ini told pytest not to recurse into `requirements`
- .gitignore still ignored requirements/private.in and .private.txt
- CLAUDE.md described the dependency commands without mentioning uv at all
- pyproject.toml pointed twice at docs/how-tos/adding-a-matrix-dependency.rst,
  which does not exist -- docs/how-tos/index.rst links to the real thing on
  docs.openedx.org, so these now point there directly

Also records in CLAUDE.md that Renovate owns routine bumps now, and that the
7-day minimum release age is configured in three places that have to stay in
agreement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.readthedocs.yaml still asked for `requirements/doc.txt`, which stopped
existing when the repo moved to uv.lock, so the config could not have
worked. Switched to Read the Docs' native uv support -- `method: uv` with
`command: sync` and the `doc` dependency group -- which resolves from the
committed uv.lock, so hosted docs would build against the same pinned
versions as CI and inherit the minimum release age recorded there.

Worth knowing: this file is dormant, not broken in production. Read the Docs
reads .readthedocs.yaml from the repository root and this one is a directory
down, so nothing reads it unless a project is created and pointed at this
path. No project exists -- both plausible subdomains 404 and nothing in the
repo references one -- and the docs are built with fail_on_warning on every
pull request by `tox -e docs` regardless. A note in the file now says so,
along with the fact that its paths are relative to backend-plugin-sample/
rather than the repository root.

Kept rather than deleted so that turning on hosted docs later is a matter of
creating the project instead of rewriting the config. Untested by
definition: with no project to build it, Read the Docs would be the first
thing to actually run this.

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

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.04%. Comparing base (1f6766e) to head (60299da).

Additional details and impacted files
@@           Coverage Diff           @@
##             main      #67   +/-   ##
=======================================
  Coverage   93.04%   93.04%           
=======================================
  Files          16       16           
  Lines         503      503           
  Branches       18       18           
=======================================
  Hits          468      468           
  Misses         24       24           
  Partials       11       11           
Flag Coverage Δ
unittests 93.04% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant