build: prepare for Renovate and add a dependency cooling-off period - #67
Draft
feanil wants to merge 10 commits into
Draft
build: prepare for Renovate and add a dependency cooling-off period#67feanil wants to merge 10 commits into
feanil wants to merge 10 commits into
Conversation
`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 Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 Requirementsworkflow calls the shared openedx pip-toolsworkflow, which runs
make upgradefrom the repository root — there is no rootMakefile, and itsADD_PATHS="requirements"would never stageuv.lock. Ithas failed every run since the move to uv. Renovate was intended for the Node
side but was never configured. Dependabot covers
github-actionsonly, soneither
package-lock.jsonhas 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
uvornpmand never enumerates the individual releases, sominimumReleaseAgedoes not apply to it. Configuring the resolvers insteadcovers transitive dependencies as well as direct ones, and means
make upgradeor
npm installon a laptop behaves exactly like the bot will.backend-plugin-sample/pyproject.toml[tool.uv] exclude-newer = "7 days"frontend-plugin-sample/.npmrc,brand-sample/.npmrcmin-release-age=7renovate.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 lockis idempotentafterwards,
uv lock --checkanduv sync --lockedstill pass, andnpm ciplus
npm run buildare unaffected in both npm packages.Why
>=0on every dependencyRenovate drops any PEP 621 dependency with no version specifier at all
(
skipReason: "unspecified-version") before it ever queries PyPI — a correctlockedVersioninuv.lockdoes not help, because the manifest is what itreads as the reference. Almost every entry here was a bare name, so Renovate
would have produced no update PRs for them.
>=0is the PEP 440 equivalent of the"*"ranges already used infrontend-plugin-sample/package.json; PEP 440 has no bare wildcard, asDjango==*is a parse error. It keeps these deliberately unconstrained — theplatform pins them itself, and a plugin that narrows them causes resolution
conflicts inside
edx-platform— while stating that intent explicitly insteadof leaving it implied by omission.
Real floors were considered and rejected: derived from
uv.lockthey would beguesses, since the lock records the newest resolvable version rather than the
oldest supported one.
Django>=5.2would block installation on a platformrunning Django 4.2, quite possibly wrongly.
The one genuine floor is
edx-lint>=6.2, which is verifiable rather thanguessed — 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 addingor 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.ymlnow runs on pull requests, invokes each per-package workflow, andends in a
gatejob that succeeds only if all of them did.gateis the onlyname branch protection has to know, and it never changes.
release.ymlcallsci.ymltoo, 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 thelast 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 upgradewas broken. It callsedx_lint write_uv_constraints, whichdoes 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-dependencieswasmissing three of edx-lint's global constraints, and
pylintrcwas still stampedGenerated by edx-lint version: 5.6.0, predating the plugin's requirement thatpii_termsbe configured — under 6.2.0 pylint aborted outright.make check-constraintsnow guards that, wired into thequalitytoxenv. Theloop 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 producepull requests that do not trigger CI when using the default
GITHUB_TOKEN.brand-samplehad no CI, and it is publishing. Itsdist/is committed andserved to real MFEs straight from GitHub via
cdn.jsdelivr.net/gh/openedx/sample-plugin@main, so it is a published artifactrather than a build leftover. Bumping
package-lock.jsondoes not regenerateit, 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.nvmrcalso 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 benefitfrom the cooldown at all.
Two runtime dependencies were undeclared.
apps.pyimportsedx_django_utilsandpipeline.pyimportscrum, but neither was in[project].dependencies. They resolved anyway because thetest-basegrouppulls in
edx-django-utils, which bringsdjango-crumtransitively — so theomission was invisible in development and in CI, and would only surface as an
ImportErrorfor someone installing the published wheel..readthedocs.yamlis dormant, not broken. It did ask for the deletedrequirements/doc.txt, but Read the Docs reads its config from the repositoryroot 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: uvsupport so it resolves fromuv.lock, with a noterecording that it is dormant and that its paths are relative to
backend-plugin-sample/. Docs are built withfail_on_warningon every pullrequest by
tox -e docsregardless.Merging this
The five
tests (...)contexts stop reporting oncebackend-ci.ymlno longerruns directly on
pull_request, so this PR cannot satisfy the current requiredchecks:
mainto justopenedx/cla.openedx/cla,gate, andcommitlint / commitlint.commitlint.ymlis deliberately left alone — it is one of the openedx standardworkflow files kept in sync by org automation, so folding it into
ci.ymlwouldbe reverted. Its context name does not depend on a matrix, so requiring it
separately costs nothing.
Merging cuts a patch release (
fix:andbuild:commits are inpatch_tags).Not included
frontend-plugin-sample/package.jsonis untouched."*"is already a validsemver range, so Renovate sees those four dependencies today —
"*"is thenpm form of
>=0. Changing them would mean inventing constraints.docs/openedx_plugin_sample.rstis stale —admin.pywas added withoutregenerating the API docs, and
tox -e docsrefreshes it in place. Left outbecause
docs:would bump this from a patch to a minor release. Worth afollow-up.
of
main, so it is a local environment problem rather than anything here.CI on
mainis green andpytestrun directly passes all 23 tests.