From 735800d4cd72d8ccc7327c9372e700644808c052 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 2 Sep 2026 11:23:31 -0400 Subject: [PATCH 1/9] fix: declare edx-django-utils and django-crum as runtime dependencies `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) --- backend-plugin-sample/pyproject.toml | 2 ++ backend-plugin-sample/uv.lock | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/backend-plugin-sample/pyproject.toml b/backend-plugin-sample/pyproject.toml index 2934fc1..b197888 100644 --- a/backend-plugin-sample/pyproject.toml +++ b/backend-plugin-sample/pyproject.toml @@ -37,6 +37,8 @@ dependencies = [ "openedx-events", "openedx-filters", "openedx-atlas", + "edx-django-utils", + "django-crum", ] [project.entry-points."lms.djangoapp"] diff --git a/backend-plugin-sample/uv.lock b/backend-plugin-sample/uv.lock index 574fe9f..67068f1 100644 --- a/backend-plugin-sample/uv.lock +++ b/backend-plugin-sample/uv.lock @@ -1350,8 +1350,10 @@ source = { editable = "." } dependencies = [ { name = "django", version = "5.2.13", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-21-openedx-plugin-sample-dev' or extra != 'group-21-openedx-plugin-sample-django60' or (extra == 'group-21-openedx-plugin-sample-django60' and extra == 'group-21-openedx-plugin-sample-doc') or (extra == 'group-21-openedx-plugin-sample-django60' and extra == 'group-21-openedx-plugin-sample-quality') or (extra == 'group-21-openedx-plugin-sample-django60' and extra == 'group-21-openedx-plugin-sample-test')" }, { name = "django", version = "6.0.4", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-21-openedx-plugin-sample-django60'" }, + { name = "django-crum" }, { name = "django-filter" }, { name = "djangorestframework" }, + { name = "edx-django-utils" }, { name = "edx-opaque-keys" }, { name = "openedx-atlas" }, { name = "openedx-core" }, @@ -1432,8 +1434,10 @@ test-base = [ [package.metadata] requires-dist = [ { name = "django" }, + { name = "django-crum" }, { name = "django-filter" }, { name = "djangorestframework" }, + { name = "edx-django-utils" }, { name = "edx-opaque-keys" }, { name = "openedx-atlas" }, { name = "openedx-core" }, From d593e9e5819586273a6efd534d695fdbfd3f7e74 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 2 Sep 2026 11:24:48 -0400 Subject: [PATCH 2/9] build: make our unconstrained dependencies explicit with >=0 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) --- backend-plugin-sample/pyproject.toml | 69 +++++++++------- backend-plugin-sample/uv.lock | 118 +++++++++++++-------------- 2 files changed, 100 insertions(+), 87 deletions(-) diff --git a/backend-plugin-sample/pyproject.toml b/backend-plugin-sample/pyproject.toml index b197888..6184e85 100644 --- a/backend-plugin-sample/pyproject.toml +++ b/backend-plugin-sample/pyproject.toml @@ -28,17 +28,25 @@ keywords = [ dynamic = ["readme", "version"] +# Unconstrained on purpose. The Open edX platform pins these itself, and a +# plugin that narrows them causes resolution conflicts when it is installed +# into edx-platform. `>=0` rather than a bare name because Renovate skips any +# dependency with no version specifier at all, so bare names would never get +# update PRs. It is the PEP 440 equivalent of the "*" ranges in +# frontend-plugin-sample/package.json -- PEP 440 has no bare wildcard. +# Please don't "tidy" these into real floors without a reason to; a floor we +# cannot justify is a compatibility claim we cannot back up. dependencies = [ - "Django", - "djangorestframework", - "django-filter", - "edx-opaque-keys", - "openedx-core", - "openedx-events", - "openedx-filters", - "openedx-atlas", - "edx-django-utils", - "django-crum", + "Django>=0", + "djangorestframework>=0", + "django-filter>=0", + "edx-opaque-keys>=0", + "openedx-core>=0", + "openedx-events>=0", + "openedx-filters>=0", + "openedx-atlas>=0", + "edx-django-utils>=0", + "django-crum>=0", ] [project.entry-points."lms.djangoapp"] @@ -52,12 +60,15 @@ Homepage = "https://github.com/openedx/sample-plugin" Repository = "https://github.com/openedx/sample-plugin" [dependency-groups] +# `>=0` here for the same reason as [project].dependencies above: Renovate +# skips dependencies with no version specifier. The two Django entries below +# are the real exception -- they define the test matrix. test-base = [ - "pytest-cov", - "pytest-django", - "code-annotations", - "edx-django-utils", - "django-extensions", + "pytest-cov>=0", + "pytest-django>=0", + "code-annotations>=0", + "edx-django-utils>=0", + "django-extensions>=0", ] # Current default Django version used by quality, docs, and the default test # matrix entry. When adding or retiring a Django version from the matrix, see @@ -72,28 +83,30 @@ django60 = [ ] quality = [ {include-group = "test"}, - "edx-lint", - "isort", - "pycodestyle", - "pydocstyle", + # A real floor, not a placeholder: `edx_lint write_uv_constraints`, used by + # `make upgrade` and `make check-constraints`, does not exist before 6.0. + "edx-lint>=6.0", + "isort>=0", + "pycodestyle>=0", + "pydocstyle>=0", ] doc = [ {include-group = "test"}, - "doc8", - "sphinx-book-theme", - "twine", - "build", - "Sphinx", + "doc8>=0", + "sphinx-book-theme>=0", + "twine>=0", + "build>=0", + "Sphinx>=0", ] ci = [ - "tox", - "tox-uv", + "tox>=0", + "tox-uv>=0", ] dev = [ {include-group = "quality"}, {include-group = "ci"}, - "diff-cover", - "edx-i18n-tools", + "diff-cover>=0", + "edx-i18n-tools>=0", ] [tool.setuptools] diff --git a/backend-plugin-sample/uv.lock b/backend-plugin-sample/uv.lock index 67068f1..2cbcb1c 100644 --- a/backend-plugin-sample/uv.lock +++ b/backend-plugin-sample/uv.lock @@ -1433,86 +1433,86 @@ test-base = [ [package.metadata] requires-dist = [ - { name = "django" }, - { name = "django-crum" }, - { name = "django-filter" }, - { name = "djangorestframework" }, - { name = "edx-django-utils" }, - { name = "edx-opaque-keys" }, - { name = "openedx-atlas" }, - { name = "openedx-core" }, - { name = "openedx-events" }, - { name = "openedx-filters" }, + { name = "django", specifier = ">=0" }, + { name = "django-crum", specifier = ">=0" }, + { name = "django-filter", specifier = ">=0" }, + { name = "djangorestframework", specifier = ">=0" }, + { name = "edx-django-utils", specifier = ">=0" }, + { name = "edx-opaque-keys", specifier = ">=0" }, + { name = "openedx-atlas", specifier = ">=0" }, + { name = "openedx-core", specifier = ">=0" }, + { name = "openedx-events", specifier = ">=0" }, + { name = "openedx-filters", specifier = ">=0" }, ] [package.metadata.requires-dev] ci = [ - { name = "tox" }, - { name = "tox-uv" }, + { name = "tox", specifier = ">=0" }, + { name = "tox-uv", specifier = ">=0" }, ] dev = [ - { name = "code-annotations" }, - { name = "diff-cover" }, + { name = "code-annotations", specifier = ">=0" }, + { name = "diff-cover", specifier = ">=0" }, { name = "django", specifier = ">=5.0,<6.0" }, - { name = "django-extensions" }, - { name = "edx-django-utils" }, - { name = "edx-i18n-tools" }, - { name = "edx-lint" }, - { name = "isort" }, - { name = "pycodestyle" }, - { name = "pydocstyle" }, - { name = "pytest-cov" }, - { name = "pytest-django" }, - { name = "tox" }, - { name = "tox-uv" }, + { name = "django-extensions", specifier = ">=0" }, + { name = "edx-django-utils", specifier = ">=0" }, + { name = "edx-i18n-tools", specifier = ">=0" }, + { name = "edx-lint", specifier = ">=6.0" }, + { name = "isort", specifier = ">=0" }, + { name = "pycodestyle", specifier = ">=0" }, + { name = "pydocstyle", specifier = ">=0" }, + { name = "pytest-cov", specifier = ">=0" }, + { name = "pytest-django", specifier = ">=0" }, + { name = "tox", specifier = ">=0" }, + { name = "tox-uv", specifier = ">=0" }, ] django60 = [ - { name = "code-annotations" }, + { name = "code-annotations", specifier = ">=0" }, { name = "django", specifier = ">=6.0,<7.0" }, - { name = "django-extensions" }, - { name = "edx-django-utils" }, - { name = "pytest-cov" }, - { name = "pytest-django" }, + { name = "django-extensions", specifier = ">=0" }, + { name = "edx-django-utils", specifier = ">=0" }, + { name = "pytest-cov", specifier = ">=0" }, + { name = "pytest-django", specifier = ">=0" }, ] doc = [ - { name = "build" }, - { name = "code-annotations" }, + { name = "build", specifier = ">=0" }, + { name = "code-annotations", specifier = ">=0" }, { name = "django", specifier = ">=5.0,<6.0" }, - { name = "django-extensions" }, - { name = "doc8" }, - { name = "edx-django-utils" }, - { name = "pytest-cov" }, - { name = "pytest-django" }, - { name = "sphinx" }, - { name = "sphinx-book-theme" }, - { name = "twine" }, + { name = "django-extensions", specifier = ">=0" }, + { name = "doc8", specifier = ">=0" }, + { name = "edx-django-utils", specifier = ">=0" }, + { name = "pytest-cov", specifier = ">=0" }, + { name = "pytest-django", specifier = ">=0" }, + { name = "sphinx", specifier = ">=0" }, + { name = "sphinx-book-theme", specifier = ">=0" }, + { name = "twine", specifier = ">=0" }, ] quality = [ - { name = "code-annotations" }, + { name = "code-annotations", specifier = ">=0" }, { name = "django", specifier = ">=5.0,<6.0" }, - { name = "django-extensions" }, - { name = "edx-django-utils" }, - { name = "edx-lint" }, - { name = "isort" }, - { name = "pycodestyle" }, - { name = "pydocstyle" }, - { name = "pytest-cov" }, - { name = "pytest-django" }, + { name = "django-extensions", specifier = ">=0" }, + { name = "edx-django-utils", specifier = ">=0" }, + { name = "edx-lint", specifier = ">=6.0" }, + { name = "isort", specifier = ">=0" }, + { name = "pycodestyle", specifier = ">=0" }, + { name = "pydocstyle", specifier = ">=0" }, + { name = "pytest-cov", specifier = ">=0" }, + { name = "pytest-django", specifier = ">=0" }, ] test = [ - { name = "code-annotations" }, + { name = "code-annotations", specifier = ">=0" }, { name = "django", specifier = ">=5.0,<6.0" }, - { name = "django-extensions" }, - { name = "edx-django-utils" }, - { name = "pytest-cov" }, - { name = "pytest-django" }, + { name = "django-extensions", specifier = ">=0" }, + { name = "edx-django-utils", specifier = ">=0" }, + { name = "pytest-cov", specifier = ">=0" }, + { name = "pytest-django", specifier = ">=0" }, ] test-base = [ - { name = "code-annotations" }, - { name = "django-extensions" }, - { name = "edx-django-utils" }, - { name = "pytest-cov" }, - { name = "pytest-django" }, + { name = "code-annotations", specifier = ">=0" }, + { name = "django-extensions", specifier = ">=0" }, + { name = "edx-django-utils", specifier = ">=0" }, + { name = "pytest-cov", specifier = ">=0" }, + { name = "pytest-django", specifier = ">=0" }, ] [[package]] From 762a3519c6aaf3f44ae905f664e42a8285890087 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 2 Sep 2026 11:29:12 -0400 Subject: [PATCH 3/9] build: pin a minimum release age for uv and npm dependencies 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) --- backend-plugin-sample/pyproject.toml | 31 ++++++++++++++++++++++++++++ backend-plugin-sample/uv.lock | 4 ++++ brand-sample/.npmrc | 13 ++++++++++++ frontend-plugin-sample/.npmrc | 21 +++++++++++++++++++ 4 files changed, 69 insertions(+) create mode 100644 brand-sample/.npmrc create mode 100644 frontend-plugin-sample/.npmrc diff --git a/backend-plugin-sample/pyproject.toml b/backend-plugin-sample/pyproject.toml index 6184e85..5000bcc 100644 --- a/backend-plugin-sample/pyproject.toml +++ b/backend-plugin-sample/pyproject.toml @@ -145,6 +145,31 @@ local_scheme = 'no-local-version' fallback_version = "0.0.0.dev0" [tool.uv] +# Supply-chain cooling-off period: never resolve to a release younger than +# this. A hijacked release is usually yanked within a few days, so waiting is +# cheap insurance for a repo where nothing is urgent. +# +# uv records the window in uv.lock as `[options] exclude-newer-span = "P7D"` +# and re-evaluates it at resolve time. A widening window cannot invalidate the +# lock -- exclude-newer only restricts which candidates are eligible, it never +# forces an upgrade -- so `uv lock --check` and `uv sync --locked` keep passing +# as time goes by. Raising the number of days here can invalidate it, though, +# if the new cutoff excludes something already locked. +# +# This is the real enforcement point, not Renovate: Renovate cannot apply its +# own `minimumReleaseAge` to a lockfile refresh, because it delegates that to +# uv and never sees the individual releases. Setting it here also means a +# developer running `make upgrade` locally gets the same behaviour as the bot. +# +# *** Keep this in sync with `minimumReleaseAge` in ../renovate.json5 and with +# *** `min-release-age` in the .npmrc files. If uv is stricter than Renovate, +# *** Renovate proposes versions uv then refuses and churns empty branches. +# +# To let one urgent security fix through without dropping the window for +# everything, add a temporary exemption rather than changing this value: +# exclude-newer-package = { the-package = false } +exclude-newer = "7 days" + # Each entry lists groups with mutually exclusive version requirements so uv can # produce a single uv.lock that contains a separate resolution for each. Add a # new pair here whenever you add a legacy-version group to [dependency-groups]. @@ -166,6 +191,12 @@ constraint-dependencies = [ # Repo-specific uv constraints merged with edx-lint's global constraints. # Local entries override global ones for the same package. # Run `make upgrade` to regenerate [tool.uv].constraint-dependencies. +# +# Any cap added here also needs a matching rule in ../renovate.json5, either +# `enabled: false` or an `allowedVersions` for that package. Otherwise +# Renovate offers a version the constraint forbids, `uv lock` refuses it, and +# the branch lands with no changes -- retried on every run. See the Django +# rule there for the worked example. uv_constraints = [ "Django<7.0", # this repo tests against Django 6.x; overrides the global Django<6.0 constraint ] diff --git a/backend-plugin-sample/uv.lock b/backend-plugin-sample/uv.lock index 2cbcb1c..0fdcf5a 100644 --- a/backend-plugin-sample/uv.lock +++ b/backend-plugin-sample/uv.lock @@ -15,6 +15,10 @@ conflicts = [[ { package = "openedx-plugin-sample", group = "django60" }, ]] +[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" + [manifest] constraints = [ { name = "django", specifier = "<7.0" }, diff --git a/brand-sample/.npmrc b/brand-sample/.npmrc new file mode 100644 index 0000000..0a44cc9 --- /dev/null +++ b/brand-sample/.npmrc @@ -0,0 +1,13 @@ +# Supply-chain cooling-off period: only install versions published more than +# this many days ago. See ../frontend-plugin-sample/.npmrc for the reasoning. +# +# npm reads the project config only from the directory holding package.json and +# does not walk up, so each npm package in this repo needs its own copy. +# +# Requires npm >= 11.10.0. Note .nvmrc here still pins Node 20, which bundles +# npm 10.8.2 and ignores this setting -- so it has no effect until that moves +# to Node 24. +# +# Keep in sync with `minimumReleaseAge` in ../renovate.json5 and with +# `exclude-newer` in ../backend-plugin-sample/pyproject.toml. +min-release-age=7 diff --git a/frontend-plugin-sample/.npmrc b/frontend-plugin-sample/.npmrc new file mode 100644 index 0000000..22b4df4 --- /dev/null +++ b/frontend-plugin-sample/.npmrc @@ -0,0 +1,21 @@ +# Supply-chain cooling-off period: only install versions published more than +# this many days ago. A hijacked release is usually yanked within a few days, +# so waiting is cheap insurance. +# +# This is the real enforcement point, not Renovate. Renovate cannot apply its +# own `minimumReleaseAge` to a lockfile refresh, and setting it here means a +# developer running `npm install` locally gets the same behaviour as the bot. +# Renovate reads this file and uses whichever of the two windows is stricter. +# +# Requires npm >= 11.10.0. .nvmrc pins Node 24, which bundles npm 11.19.0. +# Older npm ignores this and warns "Unknown project config" -- harmless, and it +# goes away once that developer is on a current Node. `npm ci` is unaffected +# either way, since it installs from the lockfile without resolving. +# +# Keep in sync with `minimumReleaseAge` in ../renovate.json5 and with +# `exclude-newer` in ../backend-plugin-sample/pyproject.toml. +# +# To let one urgent security fix through without dropping the window for +# everything, list it here rather than lowering the value above: +# min-release-age-exclude[]=the-package +min-release-age=7 From 54618e23f3a4debd15d9ca341b0b6f19024f8f95 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 2 Sep 2026 11:34:03 -0400 Subject: [PATCH 4/9] build: build and verify the brand package on pull requests brand-sample had no CI at all, which matters more than it looks. Its compiled dist/light.min.css is committed, and brand-sample/README.md tells consumers to load it directly out of this repository: brandOverride: 'https://cdn.jsdelivr.net/gh/openedx/sample-plugin@main/brand-sample/dist/light.min.css' That URL is pinned to @main, so whatever lands on the default branch is what MFEs following the brand example immediately start loading. dist/ is therefore a published artifact rather than a build leftover. Nothing rebuilt or checked it. Updating package-lock.json does not regenerate dist/, so a dependency bump that changed the build output would leave the CSS people load out of step with the tokens in this repo, with nothing to catch the drift. 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) --- .github/workflows/brand-ci.yml | 52 ++++++++++++++++++++++++++++++++++ brand-sample/.nvmrc | 2 +- 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/brand-ci.yml diff --git a/.github/workflows/brand-ci.yml b/.github/workflows/brand-ci.yml new file mode 100644 index 0000000..b5642bb --- /dev/null +++ b/.github/workflows/brand-ci.yml @@ -0,0 +1,52 @@ +name: Brand CI + +on: + # Invoked by ci.yml, which aggregates every check into a single `gate` job. + workflow_call: + +concurrency: + # See backend-ci.yml for why non-pull_request events key on the run id. + group: ${{ github.workflow }}-brand-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +defaults: + run: + working-directory: "./brand-sample" + +jobs: + build: + name: brand build + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: './brand-sample/.nvmrc' + cache: 'npm' + cache-dependency-path: './brand-sample/package-lock.json' + + # `npm ci` rather than `npm install`, for the same reason as + # frontend-ci.yml: install the lockfile exactly and fail on drift. + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + # dist/ is committed and served to real MFEs straight from GitHub via + # https://cdn.jsdelivr.net/gh/openedx/sample-plugin@main/brand-sample/dist/ + # so it is a published artifact, not a build leftover. Bumping + # package-lock.json does not regenerate it, which means a dependency + # update can silently leave the CSS people download out of step with the + # sources here. Rebuilding and diffing is what catches that. + # + # If this step fails, the fix is to run `npm run build` in brand-sample + # and commit the result. + - name: Check that the committed dist/ matches the build + run: git diff --exit-code -- dist/ diff --git a/brand-sample/.nvmrc b/brand-sample/.nvmrc index 2edeafb..a45fd52 100644 --- a/brand-sample/.nvmrc +++ b/brand-sample/.nvmrc @@ -1 +1 @@ -20 \ No newline at end of file +24 From 419eff92632d3612a4397b276c0280149d972a5e Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 2 Sep 2026 11:36:06 -0400 Subject: [PATCH 5/9] build: gate pull requests on one aggregated required check 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 job called "all required checks passed" that succeeds only if all of them did. That is the only name branch protection has to know, and it never changes whatever happens to the matrix. 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. The aggregating job 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) --- .github/workflows/backend-ci.yml | 7 +-- .github/workflows/brand-ci.yml | 4 +- .github/workflows/ci.yml | 82 +++++++++++++++++++++++++++++++ .github/workflows/frontend-ci.yml | 5 +- .github/workflows/release.yml | 15 +++--- .github/workflows/tutor-ci.yml | 5 +- 6 files changed, 94 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index f52ad4e..b4e0ec7 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -1,11 +1,8 @@ name: Python CI on: - pull_request: - branches: - - "**" - # This is so we can call CI locally from other workflows that might want to - # run CI before doing whatever task they're doing. Like the release workflow. + # Invoked by ci.yml, which runs on pull requests and aggregates every check + # into a single required one, and by release.yml before it publishes anything. workflow_call: concurrency: diff --git a/.github/workflows/brand-ci.yml b/.github/workflows/brand-ci.yml index b5642bb..65a9951 100644 --- a/.github/workflows/brand-ci.yml +++ b/.github/workflows/brand-ci.yml @@ -1,7 +1,7 @@ name: Brand CI on: - # Invoked by ci.yml, which aggregates every check into a single `gate` job. + # Invoked by ci.yml, which aggregates every check into a single required one. workflow_call: concurrency: @@ -15,7 +15,7 @@ defaults: jobs: build: - name: brand build + name: build runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f72726e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +# Every check that runs on a pull request, aggregated behind a single +# "all required checks passed" job. +# +# The point of the aggregation is branch protection. GitHub's required status +# checks are matched by name, so requiring the individual jobs means the +# required list has to spell out every matrix leg -- `tests (ubuntu-latest, +# 3.12, django52)` and friends -- and adding or retiring a Python or Django +# version silently breaks protection until somebody remembers to update the +# repository settings. Worse, two of the per-package jobs were both called +# `build`, so they could not be told apart in that list at all. +# +# "all required checks passed" succeeds only when every job it depends on +# succeeded, so it is the only name that has to be required. It never changes, +# whatever happens to the matrix. +# +# This matters for automerge specifically: GitHub's auto-merge waits for +# *required* checks and nothing else, so a check that runs but is not required +# cannot hold a merge back. + +on: + pull_request: + branches: + - "**" + # So the release workflow can run every check before it publishes anything. + workflow_call: + +concurrency: + # See backend-ci.yml for why non-pull_request events key on the run id. + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + backend: + uses: ./.github/workflows/backend-ci.yml + + frontend: + uses: ./.github/workflows/frontend-ci.yml + + tutor: + uses: ./.github/workflows/tutor-ci.yml + + brand: + uses: ./.github/workflows/brand-ci.yml + + required_checks: + # This name is what branch protection requires, so changing it means + # updating the repository settings at the same moment or every pull + # request blocks. + name: all required checks passed + # `always()` so this still reports when something upstream fails. Without + # it the job would be skipped, and a skipped required check never reports + # at all, which blocks the pull request forever instead of failing it. + if: always() + needs: [backend, frontend, tutor, brand] + runs-on: ubuntu-latest + + steps: + # Deliberately not using a third-party aggregation action. This job is + # the one thing standing between a pull request and `main`, so it is the + # last place worth adding a dependency -- and the logic is five lines. + # + # Every result must be `success`. Anything else fails this job, + # `skipped` included: no job here is conditional, so a skip means + # something went wrong rather than something was legitimately not + # needed. If a genuinely optional job is added later, this is the place + # that has to learn about it. + - name: Check that every job succeeded + run: | + results='${{ join(needs.*.result, ' ') }}' + echo "Job results: $results" + for result in $results; do + if [ "$result" != "success" ]; then + echo "::error::A required job reported '$result'." + exit 1 + fi + done + echo "All jobs succeeded." diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 4499281..9d0c94d 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -1,10 +1,7 @@ name: Frontend CI on: - pull_request: - branches: - - "**" - # So the release workflow can run these checks before it publishes anything. + # Invoked by ci.yml, which aggregates every check into a single required one. workflow_call: concurrency: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8c13ec..abe7ebf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,21 +5,18 @@ on: branches: [main] jobs: - run_backend_tests: - uses: ./.github/workflows/backend-ci.yml - - run_frontend_tests: - uses: ./.github/workflows/frontend-ci.yml - - run_tutor_tests: - uses: ./.github/workflows/tutor-ci.yml + # The same aggregate a pull request has to pass, so the release runs exactly + # the checks that were required to merge -- and picks up any package added to + # ci.yml later without needing a change here. + run_ci: + uses: ./.github/workflows/ci.yml release: # Every package this workflow publishes has to build before we tag # anything. The GitHub release and the PyPI uploads cannot be taken back, # so a package that only fails to build in its publish job would leave the # release half-finished. - needs: [run_backend_tests, run_frontend_tests, run_tutor_tests] + needs: [run_ci] runs-on: ubuntu-latest if: github.ref_name == 'main' concurrency: diff --git a/.github/workflows/tutor-ci.yml b/.github/workflows/tutor-ci.yml index df2a706..cb4e53f 100644 --- a/.github/workflows/tutor-ci.yml +++ b/.github/workflows/tutor-ci.yml @@ -1,10 +1,7 @@ name: Tutor Plugin CI on: - pull_request: - branches: - - "**" - # So the release workflow can run these checks before it publishes anything. + # Invoked by ci.yml, which aggregates every check into a single required one. workflow_call: concurrency: From bf85d5bc41530f16fd2e589be0753f99bb2a0c6f Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 2 Sep 2026 11:41:51 -0400 Subject: [PATCH 6/9] build: update edx-lint to 6.2 and refresh the files it generates `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) --- backend-plugin-sample/pylintrc | 11 +++++++++-- backend-plugin-sample/pyproject.toml | 7 +++++-- backend-plugin-sample/uv.lock | 14 +++++++++----- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/backend-plugin-sample/pylintrc b/backend-plugin-sample/pylintrc index bd140db..2d188ff 100644 --- a/backend-plugin-sample/pylintrc +++ b/backend-plugin-sample/pylintrc @@ -64,7 +64,7 @@ # SERIOUSLY. # # ------------------------------ -# Generated by edx-lint version: 5.6.0 +# Generated by edx-lint version: 6.2.0 # ------------------------------ [MASTER] ignore = migrations @@ -257,6 +257,8 @@ enable = deprecated-pragma, unrecognized-inline-option, useless-suppression, + + pii-invalid-no-pii-annotation, disable = bad-indentation, broad-exception-raised, @@ -387,4 +389,9 @@ int-import-graph = [EXCEPTIONS] overgeneral-exceptions = builtins.Exception -# 79853056a08a5009db4a6e9a8ca00c194036ac5a +[PII] +pii-terms = + email, + username + +# 80d06cad4da142244dcae7bb096c0394ef420379 diff --git a/backend-plugin-sample/pyproject.toml b/backend-plugin-sample/pyproject.toml index 5000bcc..758d35f 100644 --- a/backend-plugin-sample/pyproject.toml +++ b/backend-plugin-sample/pyproject.toml @@ -84,8 +84,8 @@ django60 = [ quality = [ {include-group = "test"}, # A real floor, not a placeholder: `edx_lint write_uv_constraints`, used by - # `make upgrade` and `make check-constraints`, does not exist before 6.0. - "edx-lint>=6.0", + # `make upgrade` and `make check-constraints`, was added in 6.2.0. + "edx-lint>=6.2", "isort>=0", "pycodestyle>=0", "pydocstyle>=0", @@ -185,6 +185,9 @@ conflicts = [ constraint-dependencies = [ "Django<7.0", "elasticsearch<7.14.0", + "social-auth-app-django<6.0.0", + "social-auth-core<5.0.0", + "pip<26.2.1", ] [tool.edx_lint] diff --git a/backend-plugin-sample/uv.lock b/backend-plugin-sample/uv.lock index 0fdcf5a..3c6a165 100644 --- a/backend-plugin-sample/uv.lock +++ b/backend-plugin-sample/uv.lock @@ -23,6 +23,9 @@ exclude-newer-span = "P7D" constraints = [ { name = "django", specifier = "<7.0" }, { name = "elasticsearch", specifier = "<7.14.0" }, + { name = "pip", specifier = "<26.2.1" }, + { name = "social-auth-app-django", specifier = "<6.0.0" }, + { name = "social-auth-core", specifier = "<5.0.0" }, ] [[package]] @@ -823,7 +826,7 @@ wheels = [ [[package]] name = "edx-lint" -version = "6.0.0" +version = "6.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -833,10 +836,11 @@ dependencies = [ { name = "pylint-celery" }, { name = "pylint-django" }, { name = "six" }, + { name = "tomlkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7a/b5/43614ee28105f436b5664fa0ead2ed6f74c125b732e030b7ceaa7f3e953f/edx_lint-6.0.0.tar.gz", hash = "sha256:078e6f3a5d54f295a43c6db59f7fdc373e139aa1bda8b7ce02fb53e15296675b", size = 48460, upload-time = "2026-03-12T18:00:53.047Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/e2/20b4782eba5bbbaeb949877666f871912db05fb22cee2eafdbc83566cffc/edx_lint-6.2.0.tar.gz", hash = "sha256:2ce94b0b1235024e7714792442ea674e1bbe2877e7ca2ca970609e74aa73dfdf", size = 55456, upload-time = "2026-08-18T12:40:35.178Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/fd/f3fbb21bb2ac75dbacbbd8a9afbffc093b09d51128ec80def128761b19b4/edx_lint-6.0.0-py3-none-any.whl", hash = "sha256:5eab30bf1beb6f678b973151b9c0add0efbd758ef33f3df52e687dc48b2c0024", size = 56807, upload-time = "2026-03-12T18:00:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/72bbb973cb654aa9d8291c438dd02b21ffb3bf2bdc9e653f9ea9048e3b20/edx_lint-6.2.0-py3-none-any.whl", hash = "sha256:0e1491f9a049927e35eb05a0fd33464b4ec912b83e387568e37f4ce5d10acb30", size = 64109, upload-time = "2026-08-18T12:40:34.102Z" }, ] [[package]] @@ -1461,7 +1465,7 @@ dev = [ { name = "django-extensions", specifier = ">=0" }, { name = "edx-django-utils", specifier = ">=0" }, { name = "edx-i18n-tools", specifier = ">=0" }, - { name = "edx-lint", specifier = ">=6.0" }, + { name = "edx-lint", specifier = ">=6.2" }, { name = "isort", specifier = ">=0" }, { name = "pycodestyle", specifier = ">=0" }, { name = "pydocstyle", specifier = ">=0" }, @@ -1496,7 +1500,7 @@ quality = [ { name = "django", specifier = ">=5.0,<6.0" }, { name = "django-extensions", specifier = ">=0" }, { name = "edx-django-utils", specifier = ">=0" }, - { name = "edx-lint", specifier = ">=6.0" }, + { name = "edx-lint", specifier = ">=6.2" }, { name = "isort", specifier = ">=0" }, { name = "pycodestyle", specifier = ">=0" }, { name = "pydocstyle", specifier = ">=0" }, From 5a554f75b7108cf1791e7aacdef5ddba73e3d724 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 2 Sep 2026 11:42:03 -0400 Subject: [PATCH 7/9] build: check that the generated uv constraints are in sync [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) --- backend-plugin-sample/Makefile | 25 +++++++++++++++++++++++-- backend-plugin-sample/tox.ini | 6 ++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/backend-plugin-sample/Makefile b/backend-plugin-sample/Makefile index a19af18..9ade075 100644 --- a/backend-plugin-sample/Makefile +++ b/backend-plugin-sample/Makefile @@ -1,6 +1,7 @@ .PHONY: clean clean_tox compile_translations coverage diff_cover docs dummy_translations \ extract_translations fake_translations help pii_check pull_translations \ - quality requirements selfcheck test test-all upgrade compile-requirements validate install_transifex_client + quality requirements selfcheck test test-all upgrade compile-requirements validate install_transifex_client \ + check-constraints .DEFAULT_GOAL := help @@ -35,10 +36,30 @@ docs: ## generate Sphinx HTML documentation, including API docs compile-requirements: ## generate the uv.lock file without upgrading packages uv lock +# Renovate handles routine dependency bumps now, one pull request per package. +# This target remains the way to re-resolve everything at once, and the only +# thing that regenerates [tool.uv].constraint-dependencies from edx-lint. upgrade: ## upgrade all packages in uv.lock and sync constraints from edx-lint - uv run --with edx-lint edx_lint write_uv_constraints pyproject.toml + uv run --with 'edx-lint>=6.2' edx_lint write_uv_constraints pyproject.toml uv lock --upgrade +# Regenerates the constraints into a scratch copy and diffs, rather than +# checking `git diff`, so that it reports only this drift and not whatever else +# happens to be uncommitted in the working tree. +check-constraints: ## fail if [tool.uv].constraint-dependencies is out of date + @cp pyproject.toml pyproject.toml.orig + @uv run --with 'edx-lint>=6.2' edx_lint write_uv_constraints pyproject.toml >/dev/null + @if cmp -s pyproject.toml.orig pyproject.toml; then \ + rm -f pyproject.toml.orig; \ + else \ + diff -u pyproject.toml.orig pyproject.toml || true; \ + mv pyproject.toml.orig pyproject.toml; \ + echo ""; \ + echo "[tool.uv].constraint-dependencies is out of date with edx-lint."; \ + echo "Run 'make upgrade' and commit the result."; \ + exit 1; \ + fi + quality: ## check coding style with pycodestyle and pylint tox -e quality diff --git a/backend-plugin-sample/tox.ini b/backend-plugin-sample/tox.ini index c7ac4b4..e52dd2f 100644 --- a/backend-plugin-sample/tox.ini +++ b/backend-plugin-sample/tox.ini @@ -82,6 +82,12 @@ commands = pydocstyle src/openedx_plugin_sample tests manage.py isort --check-only --diff tests test_utils src/openedx_plugin_sample manage.py test_settings.py make selfcheck + # Renovate cannot run `edx_lint write_uv_constraints` itself, so instead of + # regenerating the constraints on a schedule we fail here when they drift. + # 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 with it. + make check-constraints [testenv:pii_check] runner = uv-venv-lock-runner From 670d55e5b910fe42e72639da6b3de0b2d6d62d13 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 2 Sep 2026 11:44:03 -0400 Subject: [PATCH 8/9] chore: drop leftover references to the deleted requirements directory 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) --- CLAUDE.md | 16 ++++++++++++++-- backend-plugin-sample/.gitignore | 4 ---- backend-plugin-sample/pyproject.toml | 4 ++-- backend-plugin-sample/tox.ini | 2 +- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4107427..ef125fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,8 +38,20 @@ This is a **sample plugin repository** that demonstrates all major Open edX plug - Backend testing: `cd backend-plugin-sample && pytest` or `cd backend-plugin-sample && make test` - Run a single test: `cd backend-plugin-sample && pytest tests/test_models.py::test_placeholder` - Quality checks: `cd backend-plugin-sample && make quality` -- Install requirements: `cd backend-plugin-sample && make requirements` -- Compile requirements: `cd backend-plugin-sample && make compile-requirements` + +Python dependencies are managed with `uv` and locked in +`backend-plugin-sample/uv.lock`. There is no `requirements/` directory. + +- Install the dev environment: `cd backend-plugin-sample && make requirements` (`uv sync --group dev`) +- Relock without upgrading: `cd backend-plugin-sample && make compile-requirements` (`uv lock`) +- Upgrade everything and resync the edx-lint constraints: `cd backend-plugin-sample && make upgrade` + +Routine dependency bumps are handled by Renovate, one pull request per +package, so `make upgrade` is only needed for a deliberate full re-resolve. +All three package managers enforce a 7-day minimum release age: see +`exclude-newer` in `backend-plugin-sample/pyproject.toml`, `min-release-age` +in the two `.npmrc` files, and `minimumReleaseAge` in `renovate.json5`. Those +values have to stay in agreement. ## Code Style Guidelines - Python: Follow PEP 8 with max line length of 120 diff --git a/backend-plugin-sample/.gitignore b/backend-plugin-sample/.gitignore index 80a788a..cc31ca9 100644 --- a/backend-plugin-sample/.gitignore +++ b/backend-plugin-sample/.gitignore @@ -60,10 +60,6 @@ docs/modules.rst docs/sample_plugin.rst docs/sample_plugin.*.rst -# Private requirements -requirements/private.in -requirements/private.txt - # Test cruft default.db pii_report diff --git a/backend-plugin-sample/pyproject.toml b/backend-plugin-sample/pyproject.toml index 758d35f..ab3ba3b 100644 --- a/backend-plugin-sample/pyproject.toml +++ b/backend-plugin-sample/pyproject.toml @@ -72,7 +72,7 @@ test-base = [ ] # Current default Django version used by quality, docs, and the default test # matrix entry. When adding or retiring a Django version from the matrix, see -# docs/how-tos/adding-a-matrix-dependency.rst for the full process. +# https://docs.openedx.org/en/latest/developers/how-tos/manage-uv-dependency-matrix.html test = [ {include-group = "test-base"}, "Django>=5.0,<6.0", @@ -173,7 +173,7 @@ exclude-newer = "7 days" # Each entry lists groups with mutually exclusive version requirements so uv can # produce a single uv.lock that contains a separate resolution for each. Add a # new pair here whenever you add a legacy-version group to [dependency-groups]. -# See docs/how-tos/adding-a-matrix-dependency.rst for the full process. +# See https://docs.openedx.org/en/latest/developers/how-tos/manage-uv-dependency-matrix.html conflicts = [ [{group = "test"}, {group = "django60"}], ] diff --git a/backend-plugin-sample/tox.ini b/backend-plugin-sample/tox.ini index e52dd2f..673eeed 100644 --- a/backend-plugin-sample/tox.ini +++ b/backend-plugin-sample/tox.ini @@ -34,7 +34,7 @@ match-dir = (?!migrations) [pytest] DJANGO_SETTINGS_MODULE = test_settings addopts = --cov openedx_plugin_sample --cov tests --cov-report term-missing --cov-report xml -norecursedirs = .* docs requirements site-packages +norecursedirs = .* docs site-packages [testenv] runner = uv-venv-lock-runner From dfa8771832cef3a9f05e5116d65c874736f90039 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 2 Sep 2026 12:58:58 -0400 Subject: [PATCH 9/9] fix: install the docs dependencies Read the Docs actually has .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) --- backend-plugin-sample/.readthedocs.yaml | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/backend-plugin-sample/.readthedocs.yaml b/backend-plugin-sample/.readthedocs.yaml index 1d8cc9a..e7ca5d0 100644 --- a/backend-plugin-sample/.readthedocs.yaml +++ b/backend-plugin-sample/.readthedocs.yaml @@ -10,6 +10,16 @@ sphinx: configuration: docs/conf.py fail_on_warning: true +# Note: this configuration is currently dormant. Read the Docs looks for +# .readthedocs.yaml in the repository root, and this file is a directory down, +# so it is not read unless a project is created and pointed at this path +# explicitly. No such project exists today. The docs are built and +# warning-checked on every pull request by `tox -e docs` instead. +# +# It is kept, and kept correct, so that enabling hosted docs later is a matter +# of creating the project rather than rewriting this file. Every path here is +# relative to backend-plugin-sample/, not the repository root. + # Set the version of python needed to build these docs. build: os: "ubuntu-22.04" @@ -18,10 +28,11 @@ build: python: install: - - requirements: requirements/doc.txt - - # This will pip install this repo into the python environment - # if you are using this in a repo that is not pip installable - # then you should remove the following two lines. - - method: pip - path: . + # `uv sync` against the committed uv.lock, so the docs build against the + # same pinned versions as CI -- and inherits the minimum release age + # recorded in the lock. This used to be `requirements: requirements/doc.txt`, + # which stopped existing when the repo moved to uv.lock. + - method: uv + command: sync + groups: + - doc