From 735800d4cd72d8ccc7327c9372e700644808c052 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 2 Sep 2026 11:23:31 -0400 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 59a918d98d760f17e2893c616cc0bd735368ab91 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 2 Sep 2026 11:34:03 -0400 Subject: [PATCH 04/10] build: build and verify the brand package on pull requests 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) --- .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 8c56403eaad07caea6bfdfe0233cbfc06f9f334a Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 2 Sep 2026 11:36:06 -0400 Subject: [PATCH 05/10] build: gate pull requests on a single aggregated CI 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 `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) --- .github/workflows/backend-ci.yml | 7 +-- .github/workflows/brand-ci.yml | 2 +- .github/workflows/ci.yml | 80 +++++++++++++++++++++++++++++++ .github/workflows/frontend-ci.yml | 5 +- .github/workflows/release.yml | 15 +++--- .github/workflows/tutor-ci.yml | 5 +- 6 files changed, 91 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index f52ad4e..a97018a 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 `gate` job, 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..86912d0 100644 --- a/.github/workflows/brand-ci.yml +++ b/.github/workflows/brand-ci.yml @@ -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..c4965cc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI + +# Every check that runs on a pull request, aggregated behind a single `gate` +# 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. +# +# `gate` succeeds only when every job it depends on succeeded, so `gate` 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 + + gate: + name: gate + # `always()` so the gate still reports when something upstream fails. + # Without it the gate 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 the gate, + # `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..a8df0b8 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 `gate` job. 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..5e49489 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 `gate` job. workflow_call: concurrency: From 1e03f985de51ee489100b24ffb6430b6ee204b1d Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 2 Sep 2026 11:41:51 -0400 Subject: [PATCH 06/10] 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 f35f54aa7b62e056a0f6f5db686336aa98c08c66 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 2 Sep 2026 11:42:03 -0400 Subject: [PATCH 07/10] 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 43ad14be2b03bb66d95880778121b62749199997 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 2 Sep 2026 11:44:03 -0400 Subject: [PATCH 08/10] 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 d9184a9018b67c41f6fc940935a010676856aef7 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 2 Sep 2026 12:58:58 -0400 Subject: [PATCH 09/10] 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 From 60299da7ee57626cac57691ed92f620c43ba2ce8 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 2 Sep 2026 13:04:03 -0400 Subject: [PATCH 10/10] fixup! build: gate pull requests on a single aggregated CI check x --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4965cc..f3b81b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: # that has to learn about it. - name: Check that every job succeeded run: | - results='${{ join(needs.*.result, " ") }}' + results='${{ join(needs.*.result, ' ') }}' echo "Job results: $results" for result in $results; do if [ "$result" != "success" ]; then