diff --git a/.github/workflows/check-editor-releases.yml b/.github/workflows/check-editor-releases.yml index a5fa23d..a85af16 100644 --- a/.github/workflows/check-editor-releases.yml +++ b/.github/workflows/check-editor-releases.yml @@ -1,6 +1,15 @@ --- name: Check Editor Releases +# Watches exelearning/exelearning for a new editor release and opens a SYNC +# PULL REQUEST updating .editor-version and the playground blueprint pin. +# +# It deliberately does NOT build a package, publish a plugin release or push to +# main any more (DEC-0068): releases follow the release-preparation flow — a +# human PR commits the final version.php, the tag points at that commit, and +# release.yml builds from the tag. This workflow only keeps the editor pin in +# sync and leaves the release decision to a maintainer. + on: schedule: - cron: "0 8 * * *" # Daily at 8:00 UTC @@ -8,10 +17,10 @@ on: permissions: contents: write - actions: write + pull-requests: write jobs: - check_and_build: + sync_editor_pin: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -21,7 +30,6 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # Fetch latest release from exelearning/exelearning LATEST=$(gh api repos/exelearning/exelearning/releases/latest --jq '.tag_name' 2>/dev/null || echo "") if [ -z "$LATEST" ]; then echo "No release found" @@ -31,13 +39,11 @@ jobs: echo "Latest editor release: $LATEST" echo "tag=$LATEST" >> $GITHUB_OUTPUT - # Check if we already built this version - MARKER_FILE=".editor-version" CURRENT="" - if [ -f "$MARKER_FILE" ]; then - CURRENT=$(cat "$MARKER_FILE") + if [ -f .editor-version ]; then + CURRENT=$(cat .editor-version) fi - echo "Current built version: $CURRENT" + echo "Current pinned version: $CURRENT" if [ "$LATEST" = "$CURRENT" ]; then echo "Already up to date" @@ -47,57 +53,40 @@ jobs: echo "found=true" >> $GITHUB_OUTPUT fi - - name: Setup Bun - if: steps.check.outputs.found == 'true' - uses: oven-sh/setup-bun@v2 - - - name: Build static editor + - name: Open editor-pin sync pull request if: steps.check.outputs.found == 'true' env: - EXELEARNING_EDITOR_REPO_URL: https://github.com/exelearning/exelearning.git - EXELEARNING_EDITOR_REF: ${{ steps.check.outputs.tag }} - EXELEARNING_EDITOR_REF_TYPE: tag - run: make build-editor - - - name: Compute version - if: steps.check.outputs.found == 'true' - id: version + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | TAG="${{ steps.check.outputs.tag }}" - VERSION="${TAG#v}" - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "tag=$TAG" >> $GITHUB_OUTPUT + BRANCH="sync/editor-${TAG}" - - name: Create package - if: steps.check.outputs.found == 'true' - run: make package RELEASE=${{ steps.version.outputs.version }} + if gh pr list --head "$BRANCH" --state open --json number --jq 'length' | grep -qv '^0$'; then + echo "A sync PR for $TAG is already open; nothing to do." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" - - name: Update editor version marker - if: steps.check.outputs.found == 'true' - run: | - TAG="${{ steps.check.outputs.tag }}" echo "$TAG" > .editor-version # Keep the playground blueprint's editor URL in sync with .editor-version, - # otherwise the preview stays pinned to the hardcoded version. + # otherwise the preview stays pinned to the previous version. sed -i -E \ -e "s#(release=)v[0-9][0-9A-Za-z.-]*#\1$TAG#g" \ -e "s#(releases/download/)v[0-9][0-9A-Za-z.-]*#\1$TAG#g" \ -e "s#(exelearning-static-)v[0-9][0-9A-Za-z.-]*(\.zip)#\1$TAG\2#g" \ blueprint.json - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + git add .editor-version blueprint.json - git commit -m "Update editor version to $TAG" - git push + git commit -m "Sync editor pin to $TAG" + git push origin "$BRANCH" - - name: Create GitHub Release - if: steps.check.outputs.found == 'true' - uses: softprops/action-gh-release@v3 - with: - tag_name: ${{ steps.version.outputs.tag }} - name: "${{ steps.version.outputs.tag }}" - body: | - Automated build with eXeLearning editor ${{ steps.version.outputs.tag }}. - files: mod_exelearning-${{ steps.version.outputs.version }}.zip - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "Sync editor pin to $TAG" \ + --body "The editor published [$TAG](https://github.com/exelearning/exelearning/releases/tag/$TAG). This updates \`.editor-version\` and the playground blueprint pin. + +To ship a plugin release bundling this editor, follow the release-preparation flow (DEVELOPMENT.md, \"Versioning and releases\"): commit the final \`version.php\`, merge, tag that commit, and publish the release — release.yml builds the ZIP from the tag." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67766ad..88cf796 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -316,5 +316,10 @@ jobs: - name: Validate release workflow editor pinning run: bash scripts/check-release-workflow.sh + - name: Validate version metadata (DEC-0068) + run: | + bash scripts/check-version-selftest.sh + bash scripts/check-version.sh + - name: Validate release packaging run: bash scripts/check-package.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aaaee07..97d1d80 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,15 +1,20 @@ --- name: Release +# Builds the distributable ZIP for a published GitHub release (or a manual test +# build). Under the DEC-0068 version policy this workflow VALIDATES metadata and +# never mutates it: the release-preparation PR commits the final +# $plugin->version / $plugin->release before the tag is created, the tag points +# at that exact commit, and the package ships version.php verbatim. Nothing here +# derives a version from the runner date, the run date, the tag date or the +# release publication date — rebuilding the same tag always produces the same +# version.php. This workflow never commits or pushes. + on: release: types: [published] workflow_dispatch: inputs: - release_tag: - description: "Release label for package name (e.g. 1.2.3 or 1.2.3-beta)" - required: false - default: "" editor_repo_url: description: "Editor source repository URL" required: false @@ -53,16 +58,36 @@ jobs: echo "EXELEARNING_EDITOR_REF=v${VERSION_TAG}" >> $GITHUB_ENV echo "EXELEARNING_EDITOR_REF_TYPE=tag" >> $GITHUB_ENV else - INPUT_RELEASE="${{ github.event.inputs.release_tag }}" - if [ -z "$INPUT_RELEASE" ]; then - INPUT_RELEASE="manual-$(date +%Y%m%d)-${GITHUB_SHA::7}" - fi - echo "RELEASE_TAG=${INPUT_RELEASE}" >> $GITHUB_ENV + # Manual test build: version.php on a development branch carries + # release = 'dev', and check-version validates exactly that. To test + # the full release path, run this from a release-preparation branch + # whose version.php already carries the final release, tag it, and + # publish the release instead. + echo "RELEASE_TAG=dev" >> $GITHUB_ENV echo "EXELEARNING_EDITOR_REPO_URL=${{ github.event.inputs.editor_repo_url }}" >> $GITHUB_ENV echo "EXELEARNING_EDITOR_REF=${{ github.event.inputs.editor_ref }}" >> $GITHUB_ENV echo "EXELEARNING_EDITOR_REF_TYPE=${{ github.event.inputs.editor_ref_type }}" >> $GITHUB_ENV fi + - name: Validate release metadata (DEC-0068) + run: | + if [ "${{ github.event_name }}" = "release" ]; then + # The checked-out commit must be exactly the tagged commit, and + # version.php must already carry the final release (never 'dev'): + # check-version.sh asserts release == tag (without 'v'), the + # 10-digit monotonic version, and the savepoint bound. + RAW_TAG="${GITHUB_REF##*/}" + TAGGED_SHA="$(git rev-parse "refs/tags/${RAW_TAG}^{commit}")" + HEAD_SHA="$(git rev-parse HEAD)" + if [ "$TAGGED_SHA" != "$HEAD_SHA" ]; then + echo "Checked-out commit $HEAD_SHA is not the tagged commit $TAGGED_SHA" >&2 + exit 1 + fi + bash scripts/check-version.sh --release "${RELEASE_TAG}" + else + bash scripts/check-version.sh + fi + - name: Build static editor run: make build-editor diff --git a/AGENTS.md b/AGENTS.md index e6d6e43..78bad48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,7 +161,7 @@ Cerradas: **TAREA-012 / RIE-001** investigación (DEC-0019); **TAREA-009 / RIE-0 | DEC-0027 | **Aceptada** (2026-06-03) | Aceptar `.zip` (con `content.xml`) además de `.elpx` en la subida | | DEC-0028 | **Aceptada** (2026-06-03) | Enlaces del gradebook: análisis y destino del 'grade analysis' → issue #13 #4 | | DEC-0029 | **Aceptada** (2026-06-03) | Interruptor 'Calificable' por actividad (`gradeenabled`) → issue #13 | -| DEC-0030 | **Aceptada** (2026-06-03) | Versión 'sentinela' (`9999999999`/dev) en main; la real la inyecta `make package` | +| DEC-0030 | **Superseded** by DEC-0068 | Versión 'sentinela' (`9999999999`/dev) en main; la real la inyectaba `make package` | | DEC-0031 | **Aceptada** (2026-06-03) | Separar el formulario en 'Grading' y 'Attempts management' → issue #13 | | DEC-0032 | **Propuesta** (2026-06-04) | Ingesta dual de tracking: shim SCORM 1.2 + xAPI (`exe_xapi.js`) sobre tubería común → TAREA-015 | | DEC-0033 | **Propuesta** (2026-06-04) | Actualización de contenido: reemplazo del `.elpx` + origen por URL con sincronización (patrón `mod_scorm`) → TAREA-016 | @@ -182,7 +182,7 @@ Cerradas: **TAREA-012 / RIE-001** investigación (DEC-0019); **TAREA-009 / RIE-0 | DEC-0048 | **Aceptada** (2026-06-12) | Estrategia de cobertura de tests: mockear la red con `\curl::mock_response()` + mock parcial de `download_to_temp()` en vez de excluir; no excluir del scope código testeable (`excludelistfiles` vacío); xdebug/Codecov es la medida autoritativa (pcov local subacredita llamadas anidadas — artefacto, no límite); gate `codecov project: target: auto` (trinquete). Cobertura honesta 85.71%→87.2% (PR #65) | | DEC-0049 | **Aceptada** (2026-06-12) | Auditoría estándar de repositorio (2026-06-11, tras DEC-0016/DEC-0044): 9 mejoras P1–P3 implementadas (PRs #46–#54: hardening XML de estilos, thirdpartylibs en el ZIP, fidelidad backup/restore, lock de intentos, participación vs grademethod, recálculo de notas en lote, `zip_utils`, descarga del informe, Behat) + registro de **hallazgos descartados** y opciones de dirección para no re-auditar | | DEC-0050 | **Aceptada** (2026-06-12) | La herramienta de migración exeweb/exescorm vive en `mod_exelearning` (destino, dueño de los internals); orígenes como fuentes legacy de solo lectura tras `source_interface`. Endurecimiento de la rama issue #13: fix `mod_exeweb` itemid=revision (antes leía 0 → todo `nosource`); clasificación `mod_exescorm` (`.elpx` directo / 1 embebido / 0=nosource / >1=ambiguous / external+aiccurl+localsync=unsupported, `localsync` excluido por sincronización aunque tenga snapshot local); limpieza compensatoria con `course_delete_module` ante fallo parcial (sin transacción, caveat recycle bin); preservación de metadatos del cm (idnumber **nunca** se copia); validación post-extracción anti shell-vacío (`migrateextractfailed`); eventos (started/migrated/skipped/failed, patrón DEC-0041); columnas `userid`/`timemodified` (upgrade 2026061201); preflight + `\core\progress\display`. Refactor a `classes/local/migration/` (elimina `import_service`). CLI diferido | -| DEC-0051..0063 | (varias, 2026-06-12 → 2026-06-17) | **Ver índice completo en `research/docs/indices/adrs.yaml`.** Resumen: DEC-0051 eventos selectivos · DEC-0052 completion por estado · DEC-0053 búsqueda global · DEC-0054 refactor `lib.php` (extracción a clases) · DEC-0055 auditoría post-refactor · DEC-0056 tests JS (Vitest) · DEC-0057 extracción no-destructiva (BETA→STABLE) · DEC-0058 fijar tag del editor en release · DEC-0063 validación canónica del endpoint xAPI + política de versión (1.0.3 tolerante a 2.0) · DEC-0064 implementación ingesta xAPI · DEC-0065 editor solo empaquetado en release (sin instalador runtime) · DEC-0066 interruptor global del editor (modo reproductor puro) · DEC-0067 página de estilos solo-endpoint (cierra UX-01). *(DEC-0059..0062 = iframe seguro en rama `feature/secure-iframe-scorm-bridge`, aún no en `main`.)* | +| DEC-0051..0063 | (varias, 2026-06-12 → 2026-06-17) | **Ver índice completo en `research/docs/indices/adrs.yaml`.** Resumen: DEC-0051 eventos selectivos · DEC-0052 completion por estado · DEC-0053 búsqueda global · DEC-0054 refactor `lib.php` (extracción a clases) · DEC-0055 auditoría post-refactor · DEC-0056 tests JS (Vitest) · DEC-0057 extracción no-destructiva (BETA→STABLE) · DEC-0058 fijar tag del editor en release · DEC-0063 validación canónica del endpoint xAPI + política de versión (1.0.3 tolerante a 2.0) · DEC-0064 implementación ingesta xAPI · DEC-0065 editor solo empaquetado en release (sin instalador runtime) · DEC-0066 interruptor global del editor (modo reproductor puro) · DEC-0067 página de estilos solo-endpoint (cierra UX-01) · DEC-0068 versión real y monótona en main (supersede DEC-0030; empaquetado valida, no muta). *(DEC-0059..0062 = iframe seguro en rama `feature/secure-iframe-scorm-bridge`, aún no en `main`.)* | ## Restricciones inmutables diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e9b3bb8..dac425d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -172,10 +172,12 @@ This produces `mod_exelearning-.zip` with everything under a top-level Packaging (`scripts/package.sh`) uses **only `git`** — no `zip`, `rsync`, `python` or `php` — so it also works in Git Bash on Windows. It stages the working tree (including the built editor under `dist/static/`, which is -`.gitignore`d) into a throwaway index, stamps `version.php` there (`version` = -`YYYYMMDD00`, `release` = ``; the working tree is never modified), and -emits the ZIP via `git archive --format=zip`. Temporary git objects are written -to a scratch store, so your real `.git` is left untouched. +`.gitignore`d) into a throwaway index and emits the ZIP via +`git archive --format=zip`. Temporary git objects are written to a scratch +store, so your real `.git` is left untouched and the working tree is never +modified. **`version.php` ships exactly as committed** — packaging validates the +metadata (`make package` depends on `check-release-version`) but never rewrites +it, so rebuilding the same tag on any day produces the same `version.php`. The bundled editor is mandatory (DEC-0065): packaging **fails** — with a clear error and no partial ZIP — unless `dist/static/` holds a valid editor @@ -192,7 +194,38 @@ component or full relative path matches a pattern). `README.md` and `docker*`, `blueprint.json`, `phpmd*`, `scripts/`, `research/`, `docs/`, hidden files, internal docs) is not — the README links to the docs on GitHub instead. -> The committed `version.php` carries a sentinel (`9999999999` / `dev`, -> [DEC-0030](./research/decisiones/adr/DEC-0030-version-sentinela-en-main.md)); the real -> values are injected into the ZIP only. Releases are also built automatically by -> `.github/workflows/release.yml` on a published GitHub release. +## Versioning and releases + +Policy: [DEC-0068](./research/decisiones/adr/DEC-0068-version-real-monotona-en-main.md) +(supersedes DEC-0030). `main` always carries a **real, monotonic Moodle +version** and `$plugin->release = 'dev'`: + +- **No sentinels, in either direction.** `9999999999` bricks any site installed + from a checkout (every real release becomes a downgrade Moodle refuses, with + no in-product recovery); low values (`0`, `1`, `99999`) break the upgrade + protocol the other way, because `$plugin->version` must stay above every + `upgrade_mod_savepoint()` in `db/upgrade.php`. The development marker belongs + in `$plugin->release`, which is informational. +- **When to bump `$plugin->version`** (format `YYYYMMDDXX`): whenever Moodle + must detect a change — `db/`, `classes/`, JavaScript source or builds, + settings, language strings, scheduled tasks, capabilities, external services, + or other cache-sensitive metadata. The new value must be strictly greater + than the latest published version and every savepoint / `$oldversion <` guard + in `db/upgrade.php`. `scripts/check-version.sh` (run by CI and by + `make check-version`) enforces the bounds. +- **Release flow** (in this exact order): + 1. Open a release-preparation PR committing the final version and semantic + release in `version.php` (e.g. `$plugin->version = 2026072500;` + `$plugin->release = '4.0.3';`). + 2. Merge it. + 3. Create the git tag (`vX.Y.Z`) **on that exact commit**. Never modify + `version.php` after the tag exists — rebuilding a tag must never change it. + 4. Publish the GitHub release; `.github/workflows/release.yml` verifies the + checked-out commit is the tagged one, validates the metadata + (`check-version.sh --release`), builds the editor and packages the ZIP + without touching `version.php`. Workflows never commit or push. + 5. Open a follow-up PR switching `$plugin->release` back to `'dev'` and + bumping `$plugin->version` to the next valid development value. + +`make check-version` validates the committed state at any time; +`make check-release-version RELEASE=X.Y.Z` validates release metadata. diff --git a/Makefile b/Makefile index 9c18581..e91006a 100644 --- a/Makefile +++ b/Makefile @@ -212,18 +212,31 @@ clean-editor: PLUGIN_NAME = mod_exelearning -# Create a distributable ZIP package -# Usage: make package RELEASE=0.0.2 -# VERSION (YYYYMMDDXX) is auto-generated from the current date. -# Delegates to scripts/package.sh, which uses only git ("git archive") so it -# needs no zip/rsync/python/php and works in Git Bash on Windows. version.php is -# stamped in a temporary index (the working tree is never modified) and the ZIP -# is rooted at the Moodle install folder "exelearning/". -package: +# Validate the committed version metadata (DEC-0068): real, monotonic +# YYYYMMDDXX version, release = 'dev' on the development branch, strictly above +# every db/upgrade.php savepoint. +check-version: + bash scripts/check-version.sh + +# Validate release metadata before packaging: version.php must already carry the +# final semantic release (committed by the release-preparation PR) matching +# RELEASE, and the tagged commit when building from a tag. +check-release-version: @if [ -z "$(RELEASE)" ]; then \ - echo "Error: RELEASE not specified. Use 'make package RELEASE=0.0.2'"; \ + echo "Error: RELEASE is required."; \ exit 1; \ fi + bash scripts/check-version.sh --release "$(RELEASE)" + +# Create a distributable ZIP package +# Usage: make package RELEASE=4.0.3 +# The Moodle version is NOT generated here: version.php ships exactly as +# committed (DEC-0068); check-release-version validates it first. Delegates to +# scripts/package.sh, which uses only git ("git archive") so it needs no +# zip/rsync/python/php and works in Git Bash on Windows. The working tree is +# never modified and the ZIP is rooted at the Moodle install folder +# "exelearning/". +package: check-release-version @command -v git >/dev/null 2>&1 || { echo "Error: git is required to build the package."; exit 1; } @bash scripts/package.sh "$(RELEASE)" "$(PLUGIN_NAME)" diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 9c9ac41..5a801c7 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -164,6 +164,19 @@ mariadb10.11): entry by hand — the skill produces a draft, not a finished changelog. The file ships inside the release ZIP, so administrators read it. +## 10b. Version metadata (DEC-0068) + +- [ ] A release-preparation PR commits the final `$plugin->version` (YYYYMMDDXX, + strictly above the latest published version and every `db/upgrade.php` + savepoint) and `$plugin->release = 'X.Y.Z'` — never `'dev'` — in + `version.php`. `make check-release-version RELEASE=X.Y.Z` passes. +- [ ] The git tag `vX.Y.Z` is created on that exact merged commit; `version.php` + is never modified after the tag exists (rebuilding a tag must not change + it — packaging validates metadata but does not rewrite it). +- [ ] After publishing, a follow-up PR returns `$plugin->release` to `'dev'` and + bumps `$plugin->version` to the next valid development value + (`make check-version` passes). + ## 11. Exit criteria — conditions that must hold for a STABLE release `version.php:33` is `MATURITY_STABLE`; the gate was first satisfied at DEC-0057 diff --git a/research/decisiones/adr/DEC-0030-version-sentinela-en-main.md b/research/decisiones/adr/DEC-0030-version-sentinela-en-main.md index e2ab889..ffc9b3f 100644 --- a/research/decisiones/adr/DEC-0030-version-sentinela-en-main.md +++ b/research/decisiones/adr/DEC-0030-version-sentinela-en-main.md @@ -1,7 +1,7 @@ --- id: DEC-0030 titulo: "Versión 'sentinela' (9999999999/dev) en main; la versión real la inyecta make package (issue #13)" -estado: Aceptada +estado: Superseded fecha: 2026-06-03 agentes: - erseco @@ -11,11 +11,19 @@ fuentes: - REPO-004 relacionados: - DEC-0004 + - DEC-0068 herramienta_ia: interfaz: claude-code modelo: claude-opus-4-8 --- +> **Superseded por [DEC-0068](DEC-0068-version-real-monotona-en-main.md) (2026-07-24).** +> El centinela `9999999999` brickeaba las instalaciones hechas desde el checkout (toda +> release futura es un downgrade) y el estampado por fecha hacía irreproducible el build +> del mismo tag. `main` pasa a llevar una versión real y monótona; el empaquetado valida y +> nunca reescribe `version.php`. Se conservan intactos el contexto y la decisión +> originales de abajo. + ## Contexto Hasta ahora `version.php` llevaba en el árbol una versión real con fecha (`$plugin->version` = diff --git a/research/decisiones/adr/DEC-0068-version-real-monotona-en-main.md b/research/decisiones/adr/DEC-0068-version-real-monotona-en-main.md new file mode 100644 index 0000000..fb7c7bf --- /dev/null +++ b/research/decisiones/adr/DEC-0068-version-real-monotona-en-main.md @@ -0,0 +1,98 @@ +--- +id: DEC-0068 +titulo: "Versión Moodle real y monótona en main: fin del centinela; el empaquetado valida y nunca reescribe" +estado: Aceptada +fecha: 2026-07-24 +agentes: + - erseco + - amanzano3ip + - claude-code +fuentes: + - REPO-004 +relacionados: + - DEC-0030 + - DEC-0058 + - DEC-0065 +herramienta_ia: + interfaz: claude-code + modelo: claude-fable-5 +--- + +> Supersede a [DEC-0030](DEC-0030-version-sentinela-en-main.md). + +## Contexto + +[[DEC-0030]] introdujo el centinela `$plugin->version = 9999999999` / `release = 'dev'` en +`main`, con la versión real estampada por `make package` a partir de la **fecha del +runner** (`date +%Y%m%d`). El objetivo era legítimo — dejar de subir la versión a mano en +cada PR — pero la validación externa de la release 4.0.2 (DIR-03) y el PR 100 de +amanzano3ip destaparon el coste real: + +1. **El centinela máximo brickea instalaciones**: quien instala desde el checkout (clone o + "Download ZIP", vías reales porque la ficha del Marketplace enlaza el repo) registra + `9999999999` en su base de datos; toda release futura es menor y Moodle la rechaza como + downgrade, sin recuperación desde el producto. +2. **Un centinela bajo tampoco vale** (`0`, `1`, `99999`…): `$plugin->version` participa + del protocolo de instalación/upgrade — con savepoints ya en `2026072400`, una versión + inferior deja el esquema "en el futuro" respecto del código. Por el mismo motivo, el + `2026070701` propuesto originalmente en el PR 100 era conceptualmente inválido: menor + que el último savepoint existente. +3. **El estampado por fecha rompe la reproducibilidad**: reconstruir el mismo tag otro día + produce un `version.php` distinto, contra la garantía de build reproducible que + [[DEC-0058]] y [[DEC-0065]] persiguen para el editor. +4. **Soporte a ciegas**: `9999999999` no delata qué código corre un sitio. + +## Decisión + +1. **`main` lleva siempre una versión Moodle real y monótona** (`YYYYMMDDXX`). En el + momento del cambio: `2026072401` — el mínimo válido, estrictamente mayor que el + savepoint más alto (`2026072400`). **El marcador de desarrollo vive en + `$plugin->release = 'dev'`**, que es informativo y no participa del protocolo de + upgrade; nunca en `$plugin->version`. +2. **La versión se incrementa cuando Moodle deba detectar un cambio**: `db/`, `classes/`, + JS (fuente o builds), settings, strings, tareas, capabilities, servicios externos y + demás metadatos sensibles a caché. Siempre estrictamente mayor que la última release + publicada y que todo `upgrade_mod_savepoint()` / guard `$oldversion <` de + `db/upgrade.php`. +3. **El empaquetado valida y nunca muta**: `scripts/package.sh` deja de calcular versión + por fecha y de reescribir `version.php` en el índice temporal — el ZIP lleva el + `version.php` **committeado, byte a byte** (lo asserta `check-package.sh`). El resto + del índice temporal (editor en `dist/static/`, `thirdpartylibs.xml` del paquete, + marcadores `~`, `.distignore`, `git archive`) no cambia. +4. **Flujo de release en orden estricto**: (1) PR de preparación que committea versión + final + release semver en `version.php` → (2) merge → (3) tag sobre ese commit exacto + → (4) build y publicación del ZIP desde el tag → (5) PR de vuelta a desarrollo + (`release = 'dev'`, versión al siguiente valor válido). Los workflows **no** empujan a + `main` ni modifican `version.php` tras crear el tag; `release.yml` verifica que el + commit chequeado es el taggeado, que `release` no es `dev` y que casa con el tag. +5. **Guard ejecutable**: `scripts/check-version.sh` (con self-test en CI) rechaza + centinelas en ambas direcciones, exige 10 dígitos con estructura de fecha plausible, + impone la cota de savepoints (estricta en dev, `>=` en release), y en release exige + `release` = tag sin la `v`. `make package` depende de esa validación. +6. El workflow diario del editor deja de crear releases automáticas y de empujar a + `main`: ahora abre un **PR de sincronización** del pin (`.editor-version` + + blueprint); la release del plugin es siempre una decisión humana con el flujo anterior. + +## Consecuencias + +- Positivas: instalar desde el repo deja de brickear sitios (el caso que el PR 100 + diagnosticó correctamente); dos builds del mismo tag son idénticos también en + `version.php`; el número instalado delata el código; el flujo de release queda auditable + (metadatos committeados antes del tag, workflows solo-lectura). +- Para contribuidores: un PR que toque algo que Moodle deba detectar **debe subir + `$plugin->version`** (la CI lo recuerda vía guard cuando la cota de savepoints lo + fuerce; para el resto es disciplina documentada en DEVELOPMENT.md). +- Para quien gestiona releases: dos PRs por release (preparación y vuelta a dev) a cambio + de reproducibilidad y de no tener nunca un tag cuyo contenido no exista en `main`. +- Los sitios que instalaron el centinela `9999999999` siguen atascados hasta intervención + manual (documentado; este cambio evita crear nuevos casos). + +## Alternativas consideradas + +| Alternativa | Por qué se rechaza | +|---|---| +| Mantener DEC-0030 tal cual | Brickea instalaciones reales y produce builds no reproducibles; DIR-03 lo señala y el PR 100 lo demostró en su propio sitio de pruebas. | +| Centinela bajo (`0`, `1`, `99999`, `000001`) | Rompe el protocolo de upgrade en la otra dirección: versión < savepoints existentes. | +| El `2026070701` del PR 100 | Diagnóstico correcto, valor inválido: menor que el savepoint `2026072400` ya presente. | +| Estampar en el workflow en vez de en package.sh | Misma mutación con otro uniforme: el tag seguiría sin contener su propia versión y la reproducibilidad seguiría dependiendo de la fecha de ejecución. | +| Versión derivada del tag al empaquetar | El commit taggeado no contendría su versión real; imposible verificar el tag contra su contenido, y quien instala desde el checkout sigue sin versión válida. | diff --git a/research/docs/indices/adrs.yaml b/research/docs/indices/adrs.yaml index b03139e..b0ac7aa 100644 --- a/research/docs/indices/adrs.yaml +++ b/research/docs/indices/adrs.yaml @@ -29,7 +29,7 @@ items: - id: 'DEC-0027', titulo: 'Aceptar también .zip (con content.xml) además de .elpx en la subida', estado: 'Aceptada', fecha: '2026-06-03', ruta: 'decisiones/adr/DEC-0027-aceptar-zip-con-content-xml.md' - id: 'DEC-0028', titulo: "Enlaces del libro de calificaciones: análisis y destino del 'grade analysis' (issue #13 #4)", estado: 'Aceptada', fecha: '2026-06-03', ruta: 'decisiones/adr/DEC-0028-enlaces-libro-calificaciones.md' - id: 'DEC-0029', titulo: "Interruptor 'Calificable' por actividad (issue #13)", estado: 'Aceptada', fecha: '2026-06-03', ruta: 'decisiones/adr/DEC-0029-interruptor-calificable-por-actividad.md' - - id: 'DEC-0030', titulo: "Versión 'sentinela' (9999999999/dev) en main; la versión real la inyecta make package (issue #13)", estado: 'Aceptada', fecha: '2026-06-03', ruta: 'decisiones/adr/DEC-0030-version-sentinela-en-main.md' + - id: 'DEC-0030', titulo: "Versión 'sentinela' (9999999999/dev) en main; la versión real la inyecta make package (issue #13)", estado: 'Superseded', fecha: '2026-06-03', ruta: 'decisiones/adr/DEC-0030-version-sentinela-en-main.md' - id: 'DEC-0031', titulo: "Separar el formulario del recurso en 'Grading' y 'Attempts management' (issue #13)", estado: 'Aceptada', fecha: '2026-06-03', ruta: 'decisiones/adr/DEC-0031-split-grading-attempts-formulario.md' - id: 'DEC-0032', titulo: 'Ingesta dual de tracking: shim SCORM 1.2 + xAPI (exe_xapi.js) sobre una tubería común', estado: 'Propuesta', fecha: '2026-06-04', ruta: 'decisiones/adr/DEC-0032-ingesta-dual-scorm-xapi.md' - id: 'DEC-0033', titulo: 'Actualización de contenido: reemplazo del paquete .elpx y origen por URL con sincronización', estado: 'Propuesta', fecha: '2026-06-04', ruta: 'decisiones/adr/DEC-0033-actualizacion-paquete-y-origen-url.md' @@ -63,3 +63,4 @@ items: - id: 'DEC-0065', titulo: 'Editor embebido exclusivamente empaquetado en la release: eliminar el instalador/actualizador en runtime', estado: 'Aceptada', fecha: '2026-07-24', ruta: 'decisiones/adr/DEC-0065-editor-empaquetado-solo-en-release.md' - id: 'DEC-0066', titulo: 'Interruptor global del editor embebido: modo reproductor puro vía ajuste de sitio', estado: 'Aceptada', fecha: '2026-07-24', ruta: 'decisiones/adr/DEC-0066-interruptor-global-editor-embebido.md' - id: 'DEC-0067', titulo: 'La página de estilos queda como endpoint de acciones: se elimina el gestor visible duplicado', estado: 'Aceptada', fecha: '2026-07-24', ruta: 'decisiones/adr/DEC-0067-pagina-estilos-solo-endpoint.md' + - id: 'DEC-0068', titulo: 'Versión Moodle real y monótona en main: fin del centinela; el empaquetado valida y nunca reescribe', estado: 'Aceptada', fecha: '2026-07-24', ruta: 'decisiones/adr/DEC-0068-version-real-monotona-en-main.md' diff --git a/scripts/check-package.sh b/scripts/check-package.sh index 661f676..3634762 100755 --- a/scripts/check-package.sh +++ b/scripts/check-package.sh @@ -82,13 +82,11 @@ PKG="$WORK/exelearning" # the ZIP is called. [ -d "$PKG" ] || report "the ZIP must place everything under exelearning/" -# 2) The dev sentinel must be stamped with a real date version and release -# (DEC-0030); shipping 9999999999 would make every later release a downgrade. +# 2) version.php ships EXACTLY as committed (DEC-0068): the packager must not +# rewrite it, so the packaged copy is byte-identical to the working tree's. if [ -f "$PKG/version.php" ]; then - grep -qE '\$plugin->version[[:space:]]*=[[:space:]]*20[0-9]{8};' "$PKG/version.php" \ - || report "version.php was not stamped with a YYYYMMDDXX version" - grep -qE "\\\$plugin->release[[:space:]]*=[[:space:]]*'$RELEASE'" "$PKG/version.php" \ - || report "version.php was not stamped with release '$RELEASE'" + diff -q version.php "$PKG/version.php" > /dev/null \ + || report "packaged version.php differs from the committed version.php (the packager must not rewrite it)" else report "version.php is missing from the ZIP" fi @@ -159,4 +157,4 @@ if [ "$fail" -ne 0 ]; then exit 1 fi -echo "OK: release packaging requires the bundled editor, stamps version.php and thirdpartylibs.xml, strips '~' markers and keeps dev files out." +echo "OK: release packaging requires the bundled editor, ships version.php verbatim, stamps thirdpartylibs.xml, strips '~' markers and keeps dev files out." diff --git a/scripts/check-version-selftest.sh b/scripts/check-version-selftest.sh new file mode 100755 index 0000000..1bd56f0 --- /dev/null +++ b/scripts/check-version-selftest.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# +# Self-test for scripts/check-version.sh: exercises the validator against +# fixture version.php / upgrade.php files so the version policy (DEC-0068) is +# enforced by CI, not by convention. Packaging invariants (working tree +# untouched, packaged version.php identical to the committed one) live in +# scripts/check-package.sh. +# +# Usage: bash scripts/check-version-selftest.sh +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel)" +CHECK="$ROOT/scripts/check-version.sh" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +fail=0 + +# Build a fixture version.php + upgrade.php (highest savepoint 2026072400). +make_fixture() { # $1 version, $2 release + cat > "$WORK/version.php" <version = $1; +\$plugin->release = '$2'; +EOF + cat > "$WORK/upgrade.php" <<'EOF' + [args...] +run() { + local expected="$1" desc="$2"; shift 2 + local out rc=0 + out="$(CHECK_VERSION_FILE="$WORK/version.php" CHECK_UPGRADE_FILE="$WORK/upgrade.php" \ + CHECK_VERSION_TAG="${SIMTAG-}" bash "$CHECK" "$@" 2>&1)" || rc=$? + if [ "$expected" = "ok" ] && [ "$rc" -ne 0 ]; then + echo "FAIL: '$desc' should pass but failed: $out"; fail=1 + elif [ "$expected" = "fail" ] && [ "$rc" -eq 0 ]; then + echo "FAIL: '$desc' should fail but passed: $out"; fail=1 + else + echo "ok: $desc" + fi +} + +SIMTAG="" # Simulate "not on a tagged commit" unless a case overrides it. + +# 1) Valid development state. +make_fixture 2026072401 dev; run ok "dev 2026072401 passes" +# 2-4) Sentinels. +make_fixture 9999999999 dev; run fail "sentinel 9999999999 rejected" +make_fixture 99999 dev; run fail "sentinel 99999 rejected" +make_fixture 0 dev; run fail "sentinel 0 rejected" +make_fixture 1 dev; run fail "sentinel 1 rejected" +# 5) Malformed (ten digits but impossible date). +make_fixture 2026134599 dev; run fail "malformed date 2026134599 rejected" +make_fixture 202607240 dev; run fail "nine digits rejected" +# 6) Equal to the highest savepoint fails in dev mode. +make_fixture 2026072400 dev; run fail "dev version equal to highest savepoint rejected" +# 7) Lower than the highest savepoint fails. +make_fixture 2026061801 dev; run fail "dev version below highest savepoint rejected" +# 8) Greater than the highest savepoint succeeds. +make_fixture 2026072402 dev; run ok "dev version above highest savepoint passes" +# 9) Official release: tag matches release. +make_fixture 2026072500 4.0.3; SIMTAG="v4.0.3" run ok "release 4.0.3 with tag v4.0.3 passes" --release 4.0.3 +# 10) Official release: tag differs from release. +make_fixture 2026072500 4.0.3; SIMTAG="v4.0.4" run fail "release 4.0.3 with tag v4.0.4 rejected" --release 4.0.3 +# 11) Official release still carrying release='dev'. +make_fixture 2026072500 dev; SIMTAG="v4.0.3" run fail "official release with release='dev' rejected" --release 4.0.3 +# Release-preparation commit on main (no args, no tag): committed rules apply. +make_fixture 2026072500 4.0.3; SIMTAG="" run ok "release-prep commit validates without args" +# Extra guards: mismatched --release argument; dev metadata on a tagged commit. +make_fixture 2026072500 4.0.3; SIMTAG="" run fail "release 4.0.2 expected but version.php says 4.0.3" --release 4.0.2 +make_fixture 2026072401 dev; SIMTAG="v4.0.3" run fail "dev metadata on a tagged commit rejected (no args)" +make_fixture 2026072401 dev; SIMTAG="v4.0.3" run fail "dev build on a tagged commit rejected (--release dev)" --release dev + +if [ "$fail" -ne 0 ]; then + echo "check-version self-test FAILED." >&2 + exit 1 +fi +echo "OK: check-version.sh enforces the DEC-0068 version policy." diff --git a/scripts/check-version.sh b/scripts/check-version.sh new file mode 100755 index 0000000..e16ffea --- /dev/null +++ b/scripts/check-version.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# +# Validate the plugin version metadata (DEC-0068: real, monotonic versions). +# +# version.php ships exactly as committed — packaging never rewrites it — so this +# guard is what keeps the value sane: +# +# scripts/check-version.sh # validate the committed state +# scripts/check-version.sh --release 4.0.3 # official release validation +# +# Without arguments the committed state decides the rules: release = 'dev' +# applies the development rules; anything else applies the release rules with +# the committed release as the expectation (so CI stays green on the short-lived +# release-preparation commit that lands on main before tagging). +# +# Development mode asserts: no sentinel, a plausible 10-digit YYYYMMDDXX value, +# release = 'dev', and $plugin->version STRICTLY greater than every +# upgrade_mod_savepoint()/`$oldversion <` version in db/upgrade.php. +# +# Release mode additionally asserts: $plugin->release equals the expected value +# (never 'dev'), the version is >= the highest savepoint, and — when HEAD is an +# exactly-tagged commit — the tag (without its leading 'v') matches the release. +# +# Overridable for the self-test (scripts/check-version-selftest.sh): +# CHECK_VERSION_FILE path to version.php +# CHECK_UPGRADE_FILE path to db/upgrade.php +# CHECK_VERSION_TAG simulated `git describe --tags --exact-match` output +# ('' = simulate "not on a tag"; unset = ask git) +# +# Works under Bash 3.2+ (macOS, Git Bash) and Ubuntu. +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +VERSION_FILE="${CHECK_VERSION_FILE:-$ROOT/version.php}" +UPGRADE_FILE="${CHECK_UPGRADE_FILE:-$ROOT/db/upgrade.php}" + +fail() { echo "check-version: ERROR: $1" >&2; exit 1; } + +EXPECTED_RELEASE="" +if [ "${1:-}" = "--release" ]; then + EXPECTED_RELEASE="${2:-}" + [ -n "$EXPECTED_RELEASE" ] || fail "--release requires a value (e.g. --release 4.0.3)." +elif [ -n "${1:-}" ]; then + fail "unknown argument '$1'. Usage: check-version.sh [--release X.Y.Z]" +fi + +[ -f "$VERSION_FILE" ] || fail "version file not found: $VERSION_FILE" +[ -f "$UPGRADE_FILE" ] || fail "upgrade file not found: $UPGRADE_FILE" + +VERSION="$(sed -n "s/^\$plugin->version[[:space:]]*=[[:space:]]*\([0-9][0-9]*\);.*/\1/p" "$VERSION_FILE" | head -n1)" +RELEASE="$(sed -n "s/^\$plugin->release[[:space:]]*=[[:space:]]*'\([^']*\)';.*/\1/p" "$VERSION_FILE" | head -n1)" + +[ -n "$VERSION" ] || fail "could not read \$plugin->version from $VERSION_FILE" +[ -n "$RELEASE" ] || fail "could not read \$plugin->release from $VERSION_FILE" + +# 1) Sentinels are forbidden in either direction: the maximum bricks upgrades +# (every real release becomes a downgrade) and low values break the upgrade +# protocol against existing savepoints. +case "$VERSION" in + 9999999999|99999|0|1|000000|000001) + fail "\$plugin->version = $VERSION is a sentinel; use a real YYYYMMDDXX version (DEC-0068)." ;; +esac + +# 2) Exactly ten digits, plausible YYYYMMDDXX (year 20xx, month 01-12, day 01-31). +echo "$VERSION" | grep -qE '^[0-9]{10}$' \ + || fail "\$plugin->version = $VERSION must be exactly ten digits (YYYYMMDDXX)." +echo "$VERSION" | grep -qE '^20[0-9]{2}(0[1-9]|1[0-2])(0[1-9]|[12][0-9]|3[01])[0-9]{2}$' \ + || fail "\$plugin->version = $VERSION does not look like YYYYMMDDXX (invalid date part)." + +# 3) Highest version the upgrade path knows about: every savepoint plus every +# `$oldversion < N` guard (they should agree, but check both). +MAXSAVEPOINT="$( + { + sed -n 's/.*upgrade_mod_savepoint(true,[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$UPGRADE_FILE" + sed -n 's/.*\$oldversion[[:space:]]*<[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$UPGRADE_FILE" + } | sort -n | tail -n1 +)" +MAXSAVEPOINT="${MAXSAVEPOINT:-0}" + +# Without --release, validate whatever is committed. +if [ -z "$EXPECTED_RELEASE" ] && [ "$RELEASE" != "dev" ]; then + EXPECTED_RELEASE="$RELEASE" +fi + +if [ -z "$EXPECTED_RELEASE" ] || [ "$EXPECTED_RELEASE" = "dev" ]; then + # Development validation. + [ "$RELEASE" = "dev" ] \ + || fail "development validation expects \$plugin->release = 'dev', found '$RELEASE'." + [ "$VERSION" -gt "$MAXSAVEPOINT" ] \ + || fail "\$plugin->version = $VERSION must be STRICTLY greater than the highest db/upgrade.php savepoint ($MAXSAVEPOINT)." + # A dev-labelled commit must never be an officially tagged one: tags carry + # final release metadata, committed before tagging. + TAG="${CHECK_VERSION_TAG-$(git describe --tags --exact-match 2>/dev/null || true)}" + [ -z "$TAG" ] \ + || fail "release 'dev' on the tagged commit $TAG; official tags must carry a final release." + echo "check-version: OK (development): version=$VERSION release=dev savepoint-max=$MAXSAVEPOINT" + exit 0 +fi + +# Official release validation. +[ "$RELEASE" != "dev" ] \ + || fail "building an official release but \$plugin->release is still 'dev'; commit the release metadata first." +[ "$RELEASE" = "$EXPECTED_RELEASE" ] \ + || fail "\$plugin->release = '$RELEASE' does not match the expected release '$EXPECTED_RELEASE'." +[ "$VERSION" -ge "$MAXSAVEPOINT" ] \ + || fail "\$plugin->version = $VERSION is lower than the highest db/upgrade.php savepoint ($MAXSAVEPOINT)." + +TAG="${CHECK_VERSION_TAG-$(git describe --tags --exact-match 2>/dev/null || true)}" +if [ -n "$TAG" ]; then + [ "${TAG#v}" = "$RELEASE" ] \ + || fail "tag '$TAG' (without 'v': '${TAG#v}') does not match \$plugin->release = '$RELEASE'." +fi + +echo "check-version: OK (release): version=$VERSION release=$RELEASE savepoint-max=$MAXSAVEPOINT tag=${TAG:-none}" diff --git a/scripts/package.sh b/scripts/package.sh index bf5d7be..226adfb 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -9,9 +9,13 @@ # # Usage: bash scripts/package.sh [] # -# version.php is stamped inside the temporary index (the working tree is never -# modified) and the produced ZIP places everything under the Moodle install -# folder "exelearning/" (the component is mod_exelearning). +# RELEASE only names the output ZIP. version.php ships EXACTLY as committed +# (DEC-0068): the packager validates nothing and rewrites nothing there — a +# release-preparation PR commits the final version/release before tagging, and +# `make package` runs scripts/check-version.sh first. Rebuilding the same tag on +# any day therefore produces the same version.php. The produced ZIP places +# everything under the Moodle install folder "exelearning/" (the component is +# mod_exelearning). set -euo pipefail @@ -34,7 +38,6 @@ ROOT="$(git rev-parse --show-toplevel)" cd "$ROOT" OUTPUT="$ROOT/$PLUGIN_NAME-$RELEASE.zip" -DATE_VERSION="$(date +%Y%m%d)00" # The bundled editor is a release requirement (DEC-0065): the ZIP is the only # supported distribution mechanism for it, so a package without a valid editor @@ -92,14 +95,6 @@ while IFS= read -r -d '' f; do printf '%s\0' "$f" done < <(git ls-files -z -c -o) | git update-index -z --add --stdin -# Stamp version.php in the index only (working tree stays at the dev sentinels). -stamped_sha="$( - sed -e "s/\(plugin->version[[:space:]]*=[[:space:]]*\)[0-9]*/\1$DATE_VERSION/" \ - -e "s/\(plugin->release[[:space:]]*=[[:space:]]*'\)[^']*/\1$RELEASE/" version.php \ - | git hash-object -w --stdin -)" -git update-index --add --cacheinfo "100644,$stamped_sha,version.php" - # Stamp thirdpartylibs.xml in the index only: the committed file must not list # dist/static (the path is absent in a plain checkout and would break # moodle-plugin-ci install), but the release ZIP always bundles the editor @@ -131,7 +126,7 @@ while IFS= read -r -d '' langfile; do git update-index --add --cacheinfo "100644,$cleaned_sha,$langfile" done < <(git ls-files -z -- 'lang/*/exelearning.php') -echo "Packaging release $RELEASE (version $DATE_VERSION) -> $PLUGIN_NAME-$RELEASE.zip" +echo "Packaging release $RELEASE (version.php shipped as committed) -> $PLUGIN_NAME-$RELEASE.zip" TREE="$(git write-tree)" rm -f "$OUTPUT" git archive --format=zip --prefix="$INSTALL_DIR/" -o "$OUTPUT" "$TREE" diff --git a/version.php b/version.php index 0a634d2..ade7e28 100644 --- a/version.php +++ b/version.php @@ -24,8 +24,18 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 9999999999; // Dev sentinel replaced with YYYYMMDDXX by `make package` (DEC-0030). -$plugin->release = 'dev'; // Replaced with the git tag (semver) at package time. +// Real, monotonic Moodle version (YYYYMMDDXX). Never a sentinel: this value is +// part of Moodle's install/upgrade protocol and ships exactly as committed — +// packaging validates it but never rewrites it. Increment it whenever Moodle +// must detect a change (db/, classes/, JS source or builds, settings, language +// strings, tasks, capabilities, external services, other cache-sensitive +// metadata), and keep it strictly greater than the latest published version and +// every upgrade_mod_savepoint() in db/upgrade.php. The development marker lives +// in $plugin->release ('dev'); a release-preparation PR commits the final +// version + semver release BEFORE the tag is created (see DEVELOPMENT.md, +// "Versioning and releases"). +$plugin->version = 2026072401; +$plugin->release = 'dev'; $plugin->requires = 2024100700; // Moodle 4.5 LTS+. $plugin->supported = [405, 502]; // Moodle 4.5 LTS through Moodle 5.2. $plugin->component = 'mod_exelearning';