From 59bbbf551645e618a8e701faf271c35c9c91296d Mon Sep 17 00:00:00 2001 From: jwarnier Date: Tue, 1 Sep 2026 03:22:46 +0200 Subject: [PATCH 01/25] Switch to PHP 8.3-FPM + nginx single-container image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the dead PHP 5 / ubuntu 14.04 image (which cannot run the current Symfony 7 / PHP 8 LMS) with a single-container image: - PHP 8.3-FPM (www pool on 127.0.0.1:9000) - nginx (HTTP :80) serving statics + proxying .php to FPM - LMS source fetched at build time at a pinned ref (CHAMILO_LMS_REF), not vendored — keeps this repo slim and the image reproducible - memory_limit=-1 so Symfony's assets:install doesn't OOM - git dropped (all Composer deps ship zip dists) Compose (from our local setup): chamilo + MariaDB 11 + Redis 7, with DATABASE_* env names the LMS actually reads. Adds docker-compose.yml, entrypoint.sh, nginx.conf; removes the obsolete Apache vhost (000-default.conf); rewrites README/SETUP. --- .dockerignore | 11 +++- 000-default.conf | 36 ----------- Dockerfile | 145 ++++++++++++++++++++++++++------------------- README.md | 113 ++++++++++++++++------------------- SETUP.md | 92 ++++++++++++++++++++++------ docker-compose.yml | 51 ++++++++++++++++ entrypoint.sh | 15 +++++ nginx.conf | 38 ++++++++++++ 8 files changed, 323 insertions(+), 178 deletions(-) delete mode 100644 000-default.conf create mode 100644 docker-compose.yml create mode 100644 entrypoint.sh create mode 100644 nginx.conf diff --git a/.dockerignore b/.dockerignore index 36c5a45..518440e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,11 @@ -# Ignore items for docker build +# Build-context hygiene for the docker-chamilo image. +# This repo is docker-only: the LMS source is fetched at build time (curl), +# not copied from context, so the context only needs Dockerfile + nginx.conf +# + entrypoint.sh. Exclude everything else to keep the context lean. +.git +.github +*.log tmp/ +# Docs live in the repo for humans; the build never needs them in-context. +AGENTS.md +SETUP.md diff --git a/000-default.conf b/000-default.conf deleted file mode 100644 index e4138f9..0000000 --- a/000-default.conf +++ /dev/null @@ -1,36 +0,0 @@ - - # The ServerName directive sets the request scheme, hostname and port that - # the server uses to identify itself. This is used when creating - # redirection URLs. In the context of virtual hosts, the ServerName - # specifies what hostname must appear in the request's Host: header to - # match this virtual host. For the default virtual host (this file) this - # value is not decisive as it is used as a last resort host regardless. - # However, you must set it for any further virtual host explicitly. - #ServerName www.example.com - - ServerAdmin webmaster@localhost - DocumentRoot /var/www/html/chamilo2/public - # Available loglevels: trace8, ..., trace1, debug, info, notice, warn, - # error, crit, alert, emerg. - # It is also possible to configure the loglevel for particular - # modules, e.g. - #LogLevel info ssl:warn - - ErrorLog ${APACHE_LOG_DIR}/error.log - CustomLog ${APACHE_LOG_DIR}/access.log combined - - # For most configuration files from conf-available/, which are - # enabled or disabled at a global level, it is possible to - # include a line for only one particular virtual host. For example the - # following line enables the CGI configuration for this host only - # after it has been globally disabled with "a2disconf". - #Include conf-available/serve-cgi-bin.conf - - - AllowOverride All - Require all granted - - - - -# vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/Dockerfile b/Dockerfile index f851c5e..dd13317 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,61 +1,84 @@ -FROM ubuntu:14.04 -MAINTAINER Yannick Warnier - -# Keep upstart from complaining -RUN dpkg-divert --local --rename --add /sbin/initctl -RUN ln -sf /bin/true /sbin/initctl - -# Update Ubuntu and install basic PHP stuff -RUN apt-get -y update && apt-get install -y \ - curl \ - git \ - libapache2-mod-php5 \ - php5-cli \ - php5-curl \ - php5-gd \ - php5-intl \ - php5-mysql \ - wget - -RUN apt-get install -y openssh-server -RUN mkdir -p /var/run/sshd - -# Get Chamilo -RUN mkdir -p /var/www/chamilo -ADD https://github.com/chamilo/chamilo-lms/archive/v1.10.0-alpha.tar.gz /var/www/chamilo/chamilo.tar.gz -WORKDIR /var/www/chamilo -RUN tar zxf chamilo.tar.gz;rm chamilo.tar.gz;mv chamilo* www -WORKDIR www -RUN chown -R www-data:www-data \ - app \ - main/default_course_document/images \ - main/lang \ - vendor \ - web - -# Get Composer (putting the download in /root is discutible) -WORKDIR /root -RUN curl -sS https://getcomposer.org/installer | php -RUN chmod +x composer.phar -RUN mv composer.phar /usr/local/bin/composer - -# Get Chash -RUN git clone https://github.com/chamilo/chash.git chash -WORKDIR chash -RUN composer update --no-dev -RUN php -d phar.readonly=0 createPhar.php -RUN chmod +x chash.phar && mv chash.phar /usr/local/bin/chash - -# Configure and start Apache -ADD chamilo.conf /etc/apache2/sites-available/chamilo.conf -RUN a2ensite chamilo -RUN a2enmod rewrite -RUN /etc/init.d/apache2 restart -RUN echo "127.0.0.1 docker.chamilo.net" >> /etc/hosts - -# Go to Chamilo folder and install -# Soon... (this involves having a SQL server in a linked container) - -WORKDIR /var/www/chamilo/www -EXPOSE 22 80 -CMD ["/bin/bash"] +# Chamilo LMS — single-container runtime (PHP 8.3-FPM + nginx). +# +# Slim / docker-only image: the LMS source is NOT vendored into this repo. +# It is fetched at build time from a pinned ref of chamilo/chamilo-lms, so +# the image is fully reproducible and this repo stays small. +# +# This replaces the old PHP 5 / ubuntu 14.04 image, which cannot run the +# current (Symfony 7 / PHP 8) LMS. +FROM php:8.3-fpm + +# Pinned ref of chamilo/chamilo-lms. Bump to release a new LMS version. +# Accepts a git tag (e.g. v3.0.0-beta.2) or a full commit SHA. +ARG CHAMILO_LMS_REF=c75d279bf4757617286827c5c8dae02a74f438e0 + +# System packages + PHP extensions the LMS needs. +# curl/ca-certificates : fetch the pinned source; Composer zip dists (TLS) +# nginx : serves the LMS over HTTP (front controller -> FPM) +# git is intentionally omitted — every Composer dependency in composer.lock +# ships a zip dist (no VCS-only packages), so Composer downloads archives via +# the PHP zip extension instead of cloning. +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + ca-certificates \ + nginx \ + libicu-dev \ + libldap-dev \ + libpng-dev \ + libonig-dev \ + libxml2-dev \ + libxslt1-dev \ + libzip-dev \ + && docker-php-ext-install -j$(nproc) \ + bcmath \ + exif \ + gd \ + intl \ + ldap \ + opcache \ + pdo \ + pdo_mysql \ + soap \ + xsl \ + zip \ + && pecl install --onlyreqdeps --force redis \ + && docker-php-ext-enable redis \ + && rm -rf /var/lib/apt/lists/* + +# Web tier: drop the stock default vhost, install ours (listens on :80, +# proxies .php to PHP-FPM at 127.0.0.1:9000, docroot /app/chamilo-lms/public). +RUN rm -f /etc/nginx/sites-enabled/default \ + && rm -rf /var/www/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Raise PHP memory limit for CLI and any child processes. +# Symfony's `assets:install` post-install script boots the kernel and +# exhausts the 128M default; -1 keeps the build from OOM-ing. +RUN echo "memory_limit=-1" > /usr/local/etc/php/conf.d/zz-memory.ini + +# Fetch the LMS source at the pinned ref (build-time, not vendored). +# The tarball extracts to a single top-level dir (chamilo-lms-); rename +# it to /app/chamilo-lms so the path is stable for a tag or a full SHA. +RUN curl -fsSL "https://github.com/chamilo/chamilo-lms/archive/${CHAMILO_LMS_REF}.tar.gz" -o /tmp/lms.tar.gz \ + && mkdir -p /app/lms-fetch \ + && tar -xzf /tmp/lms.tar.gz -C /app/lms-fetch \ + && mv /app/lms-fetch/chamilo-lms-* /app/chamilo-lms \ + && rm -f /tmp/lms.tar.gz \ + && rm -rf /app/lms-fetch /root/.cache + +WORKDIR /app/chamilo-lms + +# Install Composer, then PHP dependencies + the post-install asset step. +# (assets:install boots the Symfony kernel; memory_limit=-1 keeps it from +# exhausting the 128M default.) +RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \ + && composer install --no-interaction --optimize-autoloader \ + && rm -rf /root/.composer /root/.cache/composer + +# Start PHP-FPM (daemon) + nginx (foreground, PID 1) on container start. +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["nginx", "-g", "daemon off;"] + +EXPOSE 80 9000 diff --git a/README.md b/README.md index 3516e73..a355a05 100644 --- a/README.md +++ b/README.md @@ -1,86 +1,75 @@ # docker-chamilo -[![](https://images.microbadger.com/badges/image/chamilo/docker-chamilo.svg)](https://microbadger.com/images/chamilo/docker-chamilo "Get your own image badge on microbadger.com") +Single-container Docker image for the [Chamilo LMS](https://www.chamilo.org). -Official Docker image for Chamilo LMS +This image bundles **PHP 8.3-FPM** and **nginx** in one container and serves the +LMS over HTTP on port **80** (nginx → PHP-FPM on `127.0.0.1:9000`). It is the +successor to the previous PHP 5 / Ubuntu 14.04 image, which cannot run the +current (Symfony 7 / PHP 8) LMS. -This image is not ready yet. Please come back soon or watch the project for updates. +## How it works -## Launching +The repo is **docker-only**: the LMS source is *not* vendored here. At build +time the `Dockerfile` downloads the LMS at a **pinned ref** of +[`chamilo/chamilo-lms`](https://github.com/chamilo/chamilo-lms) (a tag or a +full commit SHA, set by the `CHAMILO_LMS_REF` build arg) and installs its +Composer dependencies. The image is therefore fully reproducible, and this +repo stays small. -This image is currently based on Chamilo LMS 1.10 and requires a separate database container to run. -We suggest using the "mariadb" container, like so: +The container runs two processes: -``` -docker run --name mariadb -e MYSQL_ROOT_PASSWORD=pass -e MYSQL_USER=chamilo -e MYSQL_PASSWORD=chamilo -e MYSQL_DATABASE=chamilo -d mariadb -``` +| Process | Listens on | Role | +|---------|----------------|------| +| PHP-FPM | `127.0.0.1:9000` | runs the Symfony front controller | +| nginx | `0.0.0.0:80` | serves static files + proxies `.php` to FPM | -This will get you back on the command line of the Docker host. You can see the container running with ```docker ps```. +## Quick start -Then start the chamilo/docker-chamilo container: +```bash +# Build the image and start the full stack (app + MariaDB + Redis) +docker compose up -d --build -``` -docker run --link=mariadb:db --name chamilo -p 8080:80 -it chamilo/docker-chamilo +# Open the LMS +# http://localhost/ → first-run installer (create the DB, then install) ``` -At this point, the docker-chamilo image doesn't provide an installed version of Chamilo LMS, but this should be ready soon. +See [SETUP.md](SETUP.md) for first-run and production notes. -The configuration files assume the host will be "docker.chamilo.net", so you will have to define it in your host's /etc/hosts file, depending on the IP of the container. - -``` -72.17.0.10 docker.chamilo.net -``` +## Configuration -Now start your browser and load http://docker.chamilo.net. +Environment variables (read by the Symfony app — see `.env.dist` of the LMS): -## Using with a load-balancer +| Variable | Default | Notes | +|--------------------|---------|-------| +| `DATABASE_HOST` | `db` | FQDN of the database service | +| `DATABASE_PORT` | `3306` | | +| `DATABASE_NAME` | `chamilo` | | +| `DATABASE_USER` | `chamilo` | | +| `DATABASE_PASSWORD`| `chamilo` | | +| `APP_ENV` | `prod` | `dev` for verbose error pages | +| `APP_SECRET` | — | required; 32+ chars | -If you want to use a more complex system with load balancing, you might want to try out the following suite of commands: +> **Note:** the app reads `DATABASE_*`, not `DB_*`. Earlier compose examples +> used `DB_*`, which the LMS ignores. -``` -docker run --name varwww -d ywarnier/varw -``` +## Releasing a new LMS version -This will provide a shared /var/www2 partition +The version is pinned in one place — the `CHAMILO_LMS_REF` build arg in the +`Dockerfile`. To ship a new LMS version, change it to a release tag +(e.g. `v3.0.0`) or a full commit SHA, and rebuild: -``` -docker run --name mariadb -e MYSQL_ROOT_PASSWORD=pass -e MYSQL_USER=chamilo -e MYSQL_PASSWORD=chamilo -e MYSQL_DATABASE=chamilo -d mariadb -docker run --link=mariadb:db --volumes-from=varwww --name chamilo -p 8080:80 -it chamilo/docker-chamilo -# Change all configuration to point to /var/www2/chamilo/www and change the Chamilo config file (root_web) -# Also, inside app/config/configuration.php, change "session_stored_in_db" to true -# configure Chamilo on this first container then take a snapshot -docker commit -m "Live running Chamilo connected to host 'db' with existing database" {container-hash} docker-chamilo:live -docker run --link=mariadb:db --volumes-from=varwww --name chamilo2 -p 8081:80 -it docker-chamilo:live -docker run --name lb --link=chamilo:w1 --link=chamilo4:w2 -e CHAMILO_1_PORT_80_TCP_ADDR=172.17.0.10 -e CHAMILO_2_PORT_80_TCP_ADDR=172.17.0.11 -e CHAMILO_HOSTNAME=docker.chamilo.net -e CHAMILO_PATH=/ -p 8082:80 -it jasonwyatt/nginx-loadbalancer +```bash +docker build --build-arg CHAMILO_LMS_REF= -t chamilo-lms . ``` -Sadly, there's something wrong at the moment in the nginx-loadbalancer image, and you have to connect to it to change the configuration of the reverse proxy (the last container you launched). - -``` -docker ps -``` +## Requirements -(to identify the hash of the image of the load balancer (lb)) - -``` -docker exec -i -t {lb-container-hash} bash -cd /etc/nginx/sites-available/ -vi proxy.conf -``` - -(add the following *just before* proxy_pass, in the two occurrences) - -``` - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; -``` - -Now reload Nginx - -``` -service nginx reload -``` +- A MariaDB/MySQL database (provided by `docker-compose.yml` as the `db` service) +- Port 80 (HTTP) +- Optional: Redis for sessions/caching (provided as the `redis` service) -Now you should be good to go. +## Image size -Note that this will only work as long as you don't upload any file or object that needs to be stored on disk, as the two web servers will not share any disk space in the context presented above. +~1.3 GB after the source fetch (LMS source + vendor + PHP 8.3 + nginx). The +nested `.git` of the LMS source is excluded at build time, and the old +1.2 GiB `.git` from the previous approach is gone. diff --git a/SETUP.md b/SETUP.md index 105e632..55f080e 100644 --- a/SETUP.md +++ b/SETUP.md @@ -1,30 +1,86 @@ -# Creating a Chamilo 2 test containers stack +# Setup -To test Chamilo2 you can create a container based on the latest published code in GitHub as well as latest version of the base containers. **This is by no means a recommended approach for Production**. +First-run and production notes for the `docker-chamilo` image. -## Standalone Chamilo 2 container +## What's in the image -You can use the provided [Dockerfile](Dockerfile) to build your own. -Not all possible PHP extensions have been enabled but only the required ones as well as APCu as an example. +- **PHP 8.3-FPM** (the `www` pool on `127.0.0.1:9000`) +- **nginx** (HTTP on `:80`) — the web tier +- The **Chamilo LMS** source, fetched at build time at a pinned ref + (`CHAMILO_LMS_REF` in the `Dockerfile`) +- Composer dependencies + the Symfony `assets:install` step, already run -You can easily modify it to add more extensions. Layers are not squashed to make sure you can refresh Chamilo source for example by rebuilding without eventually needing refresh the previous layers. +The container starts both PHP-FPM and nginx via `entrypoint.sh`; nginx is +PID 1. -As for the database it expects you can point to yours or use a default MariaDB container when using the `docker compose up` version. +## Bring up the full stack -## Test stack (`docker compose` approach) +```bash +docker compose up -d --build +``` -Please note that you will need to create a `.env` file to define the variables of MariaDB in that case. +This starts three services (see `docker-compose.yml`): -```ini -MYSQL_ROOT_PASSWORD=securePassword -MYSQL_DATABASE=chamilo -MYSQL_USER=root -MYSQL_PASSWORD= -``` +- `chamilo` — the app (HTTP :80) +- `db` — MariaDB 11 +- `redis` — Redis 7 (sessions/cache) + +Then open **http://localhost/** — the LMS **first-run installer** walks you +through creating the database, the admin account, and completing the install. + +> The app reads `DATABASE_*` environment variables (see `.env.dist` of the +> LMS), **not** `DB_*`. The compose file sets `DATABASE_HOST=db`, etc. + +## Database + +The `db` service pre-creates a database and user: + +| Item | Value | +|------|-------| +| Root password | `chamilo` | +| Database | `chamilo` | +| User | `chamilo` | +| Password | `chamilo` | -While configuring Chamilo, use **`mariadb`** as the server hostname and whatever values you did set in the `.env` file to create the connection to the database. +For an existing database, point `DATABASE_*` at it instead. + +## Production checklist + +- Set a strong `APP_SECRET` (32+ chars). +- Terminate TLS **in front of** this container (a reverse proxy / load + balancer) — this image speaks plain HTTP on :80. +- Use a real `DATABASE_PASSWORD` and a non-root DB user. +- Back up the `db_data` volume (and `chamilo_data` for uploads). +- Pin `CHAMILO_LMS_REF` to a release tag (not a moving SHA) for + reproducible builds. +- Consider `APP_ENV=prod` (default) and disabling the debug error handler. + +## Releasing a new LMS version + +The version lives in one place — the `CHAMILO_LMS_REF` build arg in the +`Dockerfile`: + +```bash +# ship a stable release +docker build --build-arg CHAMILO_LMS_REF=v3.0.0 -t chamilo-lms . + +# or pin an exact commit +docker build --build-arg CHAMILO_LMS_REF= -t chamilo-lms . +``` -Volumes are created as named volumes to be persisted on your docker host. You can find options inside the [docker-compose.yml](docker-compose.yml) to use binded volumes or seed from an existing database export. +Rebuild and re-run `docker compose up -d --build`. The pinned ref changes +what source is fetched; everything else (PHP, extensions, nginx config) is +unchanged. -If you do not intend to rebuild every time you set up the stack, please comment out the `build` instructions in the `docker-compose.yml` section for the Chamilo container. +## Troubleshooting +- **502 / 504 from nginx** — FPM isn't up. Check `docker logs chamilo` for + the `entrypoint.sh` startup; FPM must accept on `:9000` before nginx + proxies. +- **DB connection errors** — confirm the `chamilo` service can reach `db` + (same compose network) and that `DATABASE_*` matches the `db` service. +- **`memory_limit` OOM during build** — the Dockerfile sets + `memory_limit=-1`; if you override it, `assets:install` will OOM on the + 128 M default. +- **Slow first build** — the source is downloaded at build time (~88 MB + tarball) and Composer deps are fetched; subsequent builds are cached. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6d21aeb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +# Chamilo LMS — compose. +# +# chamilo : the app (PHP 8.3-FPM + nginx, this repo's image) — HTTP on :80 +# db : MariaDB (LMS database) +# redis : session/cache (optional, mapped to 6379) +# +# The app reads DATABASE_* (see .env.dist), NOT DB_*. Values below are +# defaults for a local dev bring-up; override for production. +services: + chamilo: + build: . + ports: + - "80:80" + environment: + - DATABASE_HOST=db + - DATABASE_PORT=3306 + - DATABASE_NAME=chamilo + - DATABASE_USER=chamilo + - DATABASE_PASSWORD=chamilo + - APP_ENV=prod + - APP_SECRET=changeme-32-chars-min-aaaaaaaa + depends_on: + - db + - redis + volumes: + - chamilo_data:/app/chamilo-lms/var + restart: unless-stopped + + db: + image: mariadb:11 + environment: + - MARIADB_ROOT_PASSWORD=chamilo + - MARIADB_DATABASE=chamilo + - MARIADB_USER=chamilo + - MARIADB_PASSWORD=chamilo + volumes: + - db_data:/var/lib/mysql + restart: unless-stopped + + redis: + image: redis:7 + ports: + - "6379:6379" + volumes: + - redis_data:/data + restart: unless-stopped + +volumes: + chamilo_data: + db_data: + redis_data: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..6f10313 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Single-container entrypoint: start PHP-FPM (background) then hand PID 1 to nginx. +# nginx proxies PHP to FPM on 127.0.0.1:9000 (see nginx.conf). +set -e + +# The www pool (php:8.3-fpm default) listens on 9000. +php-fpm & + +# Wait until FPM accepts connections before nginx starts proxying to it. +until php -r 'exit((@fsockopen("127.0.0.1",9000) !== false) ? 0 : 1);' 2>/dev/null; do + sleep 0.2 +done + +# Hand PID 1 to nginx so SIGTERM/SIGQUIT reach it cleanly for graceful stop. +exec nginx -g "daemon off;" diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..2e812cf --- /dev/null +++ b/nginx.conf @@ -0,0 +1,38 @@ +# Chamilo LMS vhost — Symfony front controller -> PHP-FPM (127.0.0.1:9000). +# Installed as /etc/nginx/conf.d/default.conf (included by the http{} block). +# Serves the LMS over HTTP on :80; docroot is the Symfony public/ dir. +server { + listen 80 default_server; + server_name _; + + root /app/chamilo-lms/public; + index index.php; + + client_max_body_size 64m; + client_body_buffer_size 128k; + + # Front controller: anything that isn't a real file falls through to index.php + location / { + try_files $uri $uri/ /index.php?$args; + } + + # PHP: route all .php through the front controller + location ~ \.php$ { + fastcgi_pass 127.0.0.1:9000; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param SCRIPT_NAME /index.php; + } + + # Static assets: served directly, no PHP round-trip + location ~* \.(?:css|js|gif|svg|jpe?g|png|ico|webp|woff2?|ttf|eot|otf|map)$ { + try_files $uri =404; + access_log off; + expires 30d; + } + + # Never serve dotfiles (.env, .git, .htaccess, ...) + location ~ /\. { + deny all; + } +} From d5febfa9a8bc41fed4c45fde95e4d6bbc970c93e Mon Sep 17 00:00:00 2001 From: jwarnier Date: Tue, 1 Sep 2026 07:47:48 +0200 Subject: [PATCH 02/25] docs: add AGENTS.md agent playbook --- AGENTS.md | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5874f9e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,90 @@ +# AGENTS.md + +Playbook for AI agents (and humans) working in this repo. Read this before +touching the Dockerfile, compose, or LMS ref. + +## What this repo is + +A **docker-only** repo: it ships a `Dockerfile` that builds a single +**PHP 8.3-FPM + nginx** container for the Chamilo LMS. The LMS source is +**not** vendored — it is **fetched at build time** at a pinned ref of +`chamilo/chamilo-lms` (the `CHAMILO_LMS_REF` build arg). Do **not** commit +the LMS source tree here; that bloats the repo and defeats the slim design. + +## Key files + +| File | Purpose | +|------|---------| +| `Dockerfile` | Builds the image. Fetches LMS at `CHAMILO_LMS_REF`, installs deps + nginx, sets up FPM. | +| `nginx.conf` | The vhost: serves `public/` statics, proxies `.php` to FPM `127.0.0.1:9000`. | +| `entrypoint.sh` | Starts `php-fpm` (bg) then `exec nginx` (PID 1). | +| `docker-compose.yml` | `chamilo` + `db` (MariaDB 11) + `redis` (Redis 7). | +| `.dockerignore` | Keeps the build context lean (docs, VCS, logs). | + +## Build + +```bash +# Default: pinned ref from the Dockerfile +docker build -t chamilo-lms . + +# Override the LMS ref (tag or full 40-char SHA) +docker build --build-arg CHAMILO_LMS_REF=v3.0.0 -t chamilo-lms . +``` + +Build is **slow the first time** (~88 MB source tarball + Composer fetch); +later builds are cached. Use `podman` if that's the host runtime. + +## Run + +```bash +docker compose up -d --build +# → http://localhost/ (first-run installer) +``` + +Verify the wiring is live (no DB yet, so expect the installer / a Symfony +error page — that **proves** nginx → FPM → PHP is connected): + +```bash +curl -s -o /dev/null -w "%{http_code}\n" http://localhost/ # 200/3xx/5xx = wired +docker exec chamilo php -r 'exit((@fsockopen("127.0.0.1",9000)!==false)?0:1);' && echo "FPM up" +docker exec chamilo nginx -t # vhost is valid +``` + +## Gotchas (do not re-learn these the hard way) + +1. **`memory_limit` OOM.** Symfony's `assets:install` post-install script boots + the kernel and exhausts PHP's 128 M default. The Dockerfile writes + `memory_limit=-1` to `/usr/local/etc/php/conf.d/zz-memory.ini`. **Do not + remove that line.** If you override memory, the build OOMs in + `PhpConfigReferenceDumpPass`. +2. **Nested `.git` bloat.** The old approach copied the LMS tree (with its + 1.2 GiB `.git`) into the image. This repo fetches a **tarball** (no `.git`), + so the image is ~1.3 GB. If you ever add a `COPY` of a source tree, you + **must** `.dockerignore` the nested `.git`. +3. **Env var names.** The app reads **`DATABASE_*`** (see `.env.dist` of the + LMS), **not** `DB_*`. The compose sets `DATABASE_HOST=db` etc. Renaming + these breaks the DB connection. +4. **FPM is on `9000`, nginx on `80`.** nginx proxies `.php` to + `127.0.0.1:9000`. If you change the FPM port, update **both** + `nginx.conf` (`fastcgi_pass`) and the `entrypoint.sh` readiness check. +5. **`entrypoint.sh` runs as root** (the image default). It must start FPM + before nginx or early requests 502. The readiness loop uses PHP's + `fsockopen` (no extra tools needed). +6. **No TLS.** The image speaks plain HTTP on :80. Terminate TLS in front of + it (reverse proxy / load balancer) for production. + +## Releasing a new LMS version + +Change **one** thing — the `CHAMILO_LMS_REF` build arg in the `Dockerfile` +(to a tag like `v3.0.0` or a full commit SHA), commit, and rebuild. Prefer a +release **tag** for reproducible public builds; a moving SHA is fine for +pinning "our exact current tree" (the current pin is a master SHA newer than +the latest tag). + +## Repo hygiene + +- Keep it **slim**: no LMS source, no `vendor/`, no build artifacts. +- Keep `.dockerignore` covering `.git`, `.github`, `*.log`, `tmp/`, and the + markdown docs (they're for humans, not the build). +- The old `000-default.conf` (Apache vhost) was removed — this image is + nginx + FPM, not Apache mod_php. Don't reintroduce Apache. From 9c32827fb84852dfeb317694e2d8dedd5c00a193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Warnier?= Date: Sat, 5 Sep 2026 00:11:41 +0200 Subject: [PATCH 03/25] Add a CLAUDE.md just importing AGENTS.md --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From 1ec4838c73456543706925715ab7972ae3cac036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Warnier?= Date: Sat, 5 Sep 2026 00:13:05 +0200 Subject: [PATCH 04/25] Also docker-ignore the new CLAUDE.md --- .dockerignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.dockerignore b/.dockerignore index 518440e..025d647 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,4 +8,5 @@ tmp/ # Docs live in the repo for humans; the build never needs them in-context. AGENTS.md +CLAUDE.md SETUP.md From c4108d23f33ea60099100df289f77625d63fe314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Warnier?= Date: Sat, 5 Sep 2026 00:20:16 +0200 Subject: [PATCH 05/25] Make PHP version configurable + remove useless comment --- Dockerfile | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index dd13317..3b54e4d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,10 @@ -# Chamilo LMS — single-container runtime (PHP 8.3-FPM + nginx). +# Chamilo LMS — single-container runtime (PHP FPM + nginx). # # Slim / docker-only image: the LMS source is NOT vendored into this repo. # It is fetched at build time from a pinned ref of chamilo/chamilo-lms, so # the image is fully reproducible and this repo stays small. -# -# This replaces the old PHP 5 / ubuntu 14.04 image, which cannot run the -# current (Symfony 7 / PHP 8) LMS. -FROM php:8.3-fpm +ARG PHP_VER=8.3 +FROM php:${PHP_VER}-fpm # Pinned ref of chamilo/chamilo-lms. Bump to release a new LMS version. # Accepts a git tag (e.g. v3.0.0-beta.2) or a full commit SHA. From 1aff8b43dc126838a94112de82eac6ab2caebef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Warnier?= Date: Sat, 5 Sep 2026 01:41:00 +0200 Subject: [PATCH 06/25] Merge COPY and RUN chmod lines --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3b54e4d..d52d768 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,8 +74,7 @@ RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local && rm -rf /root/.composer /root/.cache/composer # Start PHP-FPM (daemon) + nginx (foreground, PID 1) on container start. -COPY entrypoint.sh /usr/local/bin/entrypoint.sh -RUN chmod +x /usr/local/bin/entrypoint.sh +COPY --chmod=0755 entrypoint.sh /usr/local/bin/entrypoint.sh ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] CMD ["nginx", "-g", "daemon off;"] From 1fa6e77f9ea3a85452bf3b3d433fa0368aa3d7c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Warnier?= Date: Sat, 5 Sep 2026 01:41:18 +0200 Subject: [PATCH 07/25] Remove some obsolete comments --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index a355a05..8664c64 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,7 @@ Single-container Docker image for the [Chamilo LMS](https://www.chamilo.org). This image bundles **PHP 8.3-FPM** and **nginx** in one container and serves the -LMS over HTTP on port **80** (nginx → PHP-FPM on `127.0.0.1:9000`). It is the -successor to the previous PHP 5 / Ubuntu 14.04 image, which cannot run the -current (Symfony 7 / PHP 8) LMS. +LMS over HTTP on port **80** (nginx → PHP-FPM on `127.0.0.1:9000`). ## How it works From bae7fefbff505324e91ef0aa90d76153bad57014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Warnier?= Date: Sat, 5 Sep 2026 01:41:42 +0200 Subject: [PATCH 08/25] Specify the registry to use for images --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 6d21aeb..55bc6b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,7 @@ services: restart: unless-stopped db: - image: mariadb:11 + image: docker.io/library/mariadb:11 environment: - MARIADB_ROOT_PASSWORD=chamilo - MARIADB_DATABASE=chamilo @@ -38,7 +38,7 @@ services: restart: unless-stopped redis: - image: redis:7 + image: docker.io/library/redis:7 ports: - "6379:6379" volumes: From 8c6352ddf3e90be6848905693a76e5c9af422839 Mon Sep 17 00:00:00 2001 From: jwarnier Date: Thu, 24 Sep 2026 13:28:14 +0200 Subject: [PATCH 09/25] build: strip dev-only Composer packages from the prod image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `assets:install` (a post-install auto-script) boots the Symfony kernel, so it needs a working kernel: in dev it loads the dev-only DebugBundle/ WebProfilerBundle, in prod it needs a resolvable DB. Neither is available at image-build time, so the asset step must run on the full (dev) install — it cannot be run with --no-dev in isolation. Do it in two steps instead: install full (dev + prod) so `assets:install` runs in dev as before, then sync to prod-only with `composer install --no-dev --no-scripts --optimize-autoloader` (no scripts, so the asset step does not re-run against a dev-less/prod-DB-less kernel). Final vendor tree is prod-only: 131 -> 106 packages, 490M -> 344M; image 1.35 -> 1.22 GB. The three apparent "leaks" (css-selector, stopwatch, var-dumper) are prod transitive deps (css-to-inline-styles, doctrine/migrations, error-handler/http-kernel) and are correctly retained. README size claim updated to ~1.2 GB. --- Dockerfile | 13 ++++++++++--- README.md | 8 +++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index d52d768..ca7442a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,11 +66,18 @@ RUN curl -fsSL "https://github.com/chamilo/chamilo-lms/archive/${CHAMILO_LMS_REF WORKDIR /app/chamilo-lms -# Install Composer, then PHP dependencies + the post-install asset step. -# (assets:install boots the Symfony kernel; memory_limit=-1 keeps it from -# exhausting the 128M default.) +# Install Composer, then PHP dependencies. Two steps: +# 1. full install (dev + prod) — runs `assets:install` (a dev-env kernel +# boot, which needs the dev-only DebugBundle/WebProfilerBundle), copying +# bundle assets into public/. +# 2. sync to prod-only (`--no-dev`), dropping dev packages (psalm, phpstan, +# phpunit, debug/web-profiler bundles, maker-bundle, ...). `--no-scripts` +# because re-running `assets:install` here would boot the kernel without +# the dev bundles it needs (or, in prod, need a resolvable DB — there is +# no DB at image-build time, so the asset step must run in step 1). RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \ && composer install --no-interaction --optimize-autoloader \ + && composer install --no-interaction --no-dev --no-scripts --optimize-autoloader \ && rm -rf /root/.composer /root/.cache/composer # Start PHP-FPM (daemon) + nginx (foreground, PID 1) on container start. diff --git a/README.md b/README.md index 8664c64..8dd0569 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ docker build --build-arg CHAMILO_LMS_REF= -t chamilo-lms . ## Image size -~1.3 GB after the source fetch (LMS source + vendor + PHP 8.3 + nginx). The -nested `.git` of the LMS source is excluded at build time, and the old -1.2 GiB `.git` from the previous approach is gone. +~1.2 GB after the source fetch (LMS source + prod-only vendor + PHP 8.3 + +nginx). Dev-only Composer packages are stripped at build time (see the +Dockerfile's two-step `composer install`), and the nested `.git` of the LMS +source is excluded at build time — the old 1.2 GiB `.git` from the previous +approach is gone. From 416b357764abcadf9ced38312ec86a00b07f2c0a Mon Sep 17 00:00:00 2001 From: jwarnier Date: Thu, 24 Sep 2026 15:29:29 +0200 Subject: [PATCH 10/25] build: declare only the real listening port (EXPOSE 80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FPM binds 127.0.0.1:9000 and is never port-mapped — it is only reachable from inside the container via nginx's fastcgi_pass. Declaring EXPOSE 9000 implied a usable port and misdescribes the image's public surface (the only thing exposed is nginx on :80). --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index ca7442a..0ea39f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -85,4 +85,4 @@ COPY --chmod=0755 entrypoint.sh /usr/local/bin/entrypoint.sh ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] CMD ["nginx", "-g", "daemon off;"] -EXPOSE 80 9000 +EXPOSE 80 From 4d6e1492ba0c64b14d927333649a760ed6e971b9 Mon Sep 17 00:00:00 2001 From: jwarnier Date: Thu, 24 Sep 2026 15:41:05 +0200 Subject: [PATCH 11/25] build: healthchecks + real depends_on (service_healthy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain `depends_on: - db` only waits for container *start*, not MariaDB readiness, so chamilo could boot before the DB accepted connections and error on the first DB hit at install time. - db: healthcheck `mariadb-admin ping -u root -pchamilo`. mariadb:11 ships the client as `mariadb-admin`, NOT `mysqladmin` (the common recipe is wrong for the 11.x line) — verified against a real mariadb:11 container. - redis: healthcheck `redis-cli ping`. - chamilo: `depends_on` now `service_healthy` for both, so it starts only after the DB and Redis actually accept connections. - image HEALTHCHECK: liveness probe — nginx answers ANY HTTP status (a fresh LMS returns 302 to the installer; that still means the web tier is up). Only a connection failure is unhealthy. Verified: fresh image returns 302 -> probe healthy; db flips healthy in ~15s; redis healthy; `podman compose config` parses. --- Dockerfile | 8 ++++++++ docker-compose.yml | 20 ++++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0ea39f1..ab62ac5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -86,3 +86,11 @@ ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] CMD ["nginx", "-g", "daemon off;"] EXPOSE 80 + +# Liveness probe: the web tier (nginx -> FPM) is up and answering HTTP. +# Accepts ANY status code — a fresh LMS returns 5xx until the installer runs, +# and that still means the container is alive and serving. Only a connection +# failure (no response / FPM down) is "unhealthy". curl is already installed +# (source fetch); --max-time caps the wait so a stuck FPM worker can't hang. +HEALTHCHECK --start-period=15s --interval=30s --timeout=5s --retries=3 \ + CMD ["sh", "-c", "curl -s --max-time 5 -o /dev/null -w '%{http_code}' http://127.0.0.1/ 2>/dev/null | grep -qE '^[0-9]{3}$'"] diff --git a/docker-compose.yml b/docker-compose.yml index 55bc6b4..b5e0f0f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,8 +20,10 @@ services: - APP_ENV=prod - APP_SECRET=changeme-32-chars-min-aaaaaaaa depends_on: - - db - - redis + db: + condition: service_healthy + redis: + condition: service_healthy volumes: - chamilo_data:/app/chamilo-lms/var restart: unless-stopped @@ -35,6 +37,15 @@ services: - MARIADB_PASSWORD=chamilo volumes: - db_data:/var/lib/mysql + # "ready to accept connections" — used by chamilo's depends_on + # (condition: service_healthy). mariadb:11 ships the client as + # `mariadb-admin` (not `mysqladmin`); -u root:-pchamilo authenticates. + healthcheck: + test: ["CMD", "mariadb-admin", "ping", "-h", "localhost", "-u", "root", "-pchamilo"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s restart: unless-stopped redis: @@ -43,6 +54,11 @@ services: - "6379:6379" volumes: - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 restart: unless-stopped volumes: From 79b7101dca4f686751719f73dcbddc1190935788 Mon Sep 17 00:00:00 2001 From: jwarnier Date: Thu, 24 Sep 2026 16:14:50 +0200 Subject: [PATCH 12/25] build: scope memory_limit=-1 to the build; bound the FPM pool at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global `memory_limit=-1` ini is required for the build (assets:install's child `php` reads it), but it left the FPM *runtime* unlimited too — a single web request could allocate unboundedly. Keep the -1 for the build and bound the `www` pool to 256M via `php_admin_value[memory_limit]` appended to www.conf: a per-pool directive that outranks the ini, so only the web tier is bounded, and the -1 is not left in place for it. `php-fpm -t` confirms the pool config still validates. --- Dockerfile | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index ab62ac5..edf250d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,10 +49,15 @@ RUN rm -f /etc/nginx/sites-enabled/default \ && rm -rf /var/www/html COPY nginx.conf /etc/nginx/conf.d/default.conf -# Raise PHP memory limit for CLI and any child processes. -# Symfony's `assets:install` post-install script boots the kernel and -# exhausts the 128M default; -1 keeps the build from OOM-ing. -RUN echo "memory_limit=-1" > /usr/local/etc/php/conf.d/zz-memory.ini +# PHP memory limit, split by context: +# * build: the global CLI ini is -1 so `assets:install` (a child `php` boot +# of the Symfony kernel, which reads the ini) can't OOM on the 128M +# default. That line is required for the build (see AGENTS.md gotcha #1). +# * runtime: the FPM `www` pool is bounded to 256M via php_admin_value — a +# per-pool directive that outranks the ini — so a web request can't +# allocate unboundedly. The -1 is NOT left in place for the web tier. +RUN echo "memory_limit=-1" > /usr/local/etc/php/conf.d/zz-memory.ini \ + && echo "php_admin_value[memory_limit] = 256M" >> /usr/local/etc/php-fpm.d/www.conf # Fetch the LMS source at the pinned ref (build-time, not vendored). # The tarball extracts to a single top-level dir (chamilo-lms-); rename From cd9ac7e6418aea08364797378ddad67c8b1d105a Mon Sep 17 00:00:00 2001 From: jwarnier Date: Thu, 24 Sep 2026 16:19:01 +0200 Subject: [PATCH 13/25] build: hand the app tree to the FPM runtime user (www-data) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `www` pool already runs as `www-data` (www.conf), but the app tree was root-owned, so the pool workers (www-data, "other") were r-x on the runtime dirs Symfony writes constantly (var/cache, var/log, var/upload) — proven Permission-denied, so the app 500s on its first cache/log write. `chown -R www-data:www-data /app/chamilo-lms` makes the pool user own the tree. Verified: var/cache and var/log now writable by www-data. --- Dockerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Dockerfile b/Dockerfile index edf250d..94c1b17 100644 --- a/Dockerfile +++ b/Dockerfile @@ -85,6 +85,12 @@ RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local && composer install --no-interaction --no-dev --no-scripts --optimize-autoloader \ && rm -rf /root/.composer /root/.cache/composer +# The FPM `www` pool already runs as `www-data` (www.conf), but the app tree +# is root-owned, so the pool workers couldn't write to the runtime dirs +# Symfony writes constantly (var/cache, var/log, var/upload) — proven +# Permission-denied. Hand the tree to the runtime user. +RUN chown -R www-data:www-data /app/chamilo-lms + # Start PHP-FPM (daemon) + nginx (foreground, PID 1) on container start. COPY --chmod=0755 entrypoint.sh /usr/local/bin/entrypoint.sh ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] From 56e55d2e0ba964e5f68f9aed9b6b21c15eb356cf Mon Sep 17 00:00:00 2001 From: jwarnier Date: Thu, 24 Sep 2026 16:20:40 +0200 Subject: [PATCH 14/25] build: blanket *.md in .dockerignore instead of a per-file list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build never copies docs in-context (only Dockerfile + nginx.conf + entrypoint.sh), so the individual AGENTS.md/CLAUDE.md/SETUP.md entries were maintenance overhead — and README.md wasn't even listed (a latent gap, since it too never ships). A blanket `*.md` covers all current and future docs. --- .dockerignore | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index 025d647..f4cb79c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,7 +6,7 @@ .github *.log tmp/ -# Docs live in the repo for humans; the build never needs them in-context. -AGENTS.md -CLAUDE.md -SETUP.md +# Docs live in the repo for humans; the build never needs them in-context +# (the only files the build uses are Dockerfile + nginx.conf + entrypoint.sh). +# Blanket pattern so any future .md is auto-excluded, no per-file list to keep. +*.md From 8a8611baaa541f7147565ef7214c82d45b463e32 Mon Sep 17 00:00:00 2001 From: jwarnier Date: Thu, 24 Sep 2026 16:25:23 +0200 Subject: [PATCH 15/25] docs: sync AGENTS.md with the committed Dockerfile/compose changes Gotcha #1 now documents the memory_limit build/runtime split (the -1 ini is for the build's assets:install child; the www pool is bounded at 256M via php_admin_value). Gotcha #2 image size corrected ~1.3 -> ~1.2 GB after the prod-only vendor strip. Repo hygiene notes the blanket *.md .dockerignore. --- AGENTS.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5874f9e..cef66f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,14 +52,20 @@ docker exec chamilo nginx -t # vhost is valid ## Gotchas (do not re-learn these the hard way) -1. **`memory_limit` OOM.** Symfony's `assets:install` post-install script boots - the kernel and exhausts PHP's 128 M default. The Dockerfile writes - `memory_limit=-1` to `/usr/local/etc/php/conf.d/zz-memory.ini`. **Do not - remove that line.** If you override memory, the build OOMs in - `PhpConfigReferenceDumpPass`. +1. **`memory_limit` OOM — split by context.** Symfony's `assets:install` + post-install script boots the kernel in a child `php` process and + exhausts PHP's 128 M default. The Dockerfile writes `memory_limit=-1` to + `/usr/local/etc/php/conf.d/zz-memory.ini` **for the build** — the child reads + the ini, and if you override memory the build OOMs in + `PhpConfigReferenceDumpPass`. It **also** bounds the runtime FPM `www` pool + to `256M` via `php_admin_value[memory_limit]` appended to + `/usr/local/etc/php-fpm.d/www.conf` — a per-pool directive that outranks the + ini — so a web request can't allocate unboundedly. **Do not remove either + part:** the build needs the `-1` ini, and the pool limit is what keeps the + runtime from running unlimited. 2. **Nested `.git` bloat.** The old approach copied the LMS tree (with its 1.2 GiB `.git`) into the image. This repo fetches a **tarball** (no `.git`), - so the image is ~1.3 GB. If you ever add a `COPY` of a source tree, you + so the image is ~1.2 GB. If you ever add a `COPY` of a source tree, you **must** `.dockerignore` the nested `.git`. 3. **Env var names.** The app reads **`DATABASE_*`** (see `.env.dist` of the LMS), **not** `DB_*`. The compose sets `DATABASE_HOST=db` etc. Renaming @@ -84,7 +90,7 @@ the latest tag). ## Repo hygiene - Keep it **slim**: no LMS source, no `vendor/`, no build artifacts. -- Keep `.dockerignore` covering `.git`, `.github`, `*.log`, `tmp/`, and the - markdown docs (they're for humans, not the build). +- Keep `.dockerignore` covering `.git`, `.github`, `*.log`, `tmp/`, and a + blanket `*.md` for the docs (they're for humans, not the build). - The old `000-default.conf` (Apache vhost) was removed — this image is nginx + FPM, not Apache mod_php. Don't reintroduce Apache. From eb07b04cd73f865c6adcf5c8a81ac859ae177c2d Mon Sep 17 00:00:00 2001 From: jwarnier Date: Thu, 24 Sep 2026 17:20:38 +0200 Subject: [PATCH 16/25] ci: add docker build job (catches fetch + memory OOM at push time) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub-hosted runners are free and unmetered for public repositories, so this costs nothing. It runs the exact build CI exercises (default CHAMILO_LMS_REF, no override) and fails the build if the pinned ref no longer fetches or the memory-limit OOM regresses — the two breakages already proven in this image. 90 min timeout for cold-cache builds. Concurrency guard cancels in-progress runs on re-push. --- .github/workflows/build.yml | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..3f2cf18 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,39 @@ +# CI: the image must build. +# +# This is a docker-only repo — the only build that matters is `docker build` +# with the pinned CHAMILO_LMS_REF (the Dockerfile's default ARG). This job +# runs that exact build on push and on manual dispatch, catching the two +# breakages this image is prone to instead of finding them at release time: +# * the `assets:install` memory OOM (AGENTS.md gotcha #1), and +# * a broken source fetch (pinned ref no longer present upstream). +# +# Cost: GitHub-hosted runners are free and unmetered for public repositories, +# so this is not metered against the free plan (the 2,000-min/500 MB quota +# applies only to private repos). The only real cost is build time per push; +# the LMS fetch + Composer step is the slow part and is not cached between +# runs, so each green build takes a few minutes. +name: build + +on: + push: + workflow_dispatch: + +# Cancel in-progress runs when a new commit lands on the same branch. +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +jobs: + docker-build: + name: Build image + runs-on: ubuntu-latest + # A full build from scratch (LMS tarball + Composer) can exceed the + # default 1h job timeout on a cold cache; give it headroom. + timeout-minutes: 90 + steps: + - name: Checkout + uses: actions/checkout@v4 + # Build with the repo's own pinned ref — no --build-arg override, so + # the CI build exercises exactly what a real build ships. + - name: docker build + run: docker build -t chamilo-lms-ci . From bed4d67a398e1790beba32d5266294d74fb3c242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Warnier?= Date: Thu, 24 Sep 2026 17:23:09 +0200 Subject: [PATCH 17/25] Bump actions/checkout to latest available major release --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3f2cf18..f42e5b1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,7 +32,7 @@ jobs: timeout-minutes: 90 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 # Build with the repo's own pinned ref — no --build-arg override, so # the CI build exercises exactly what a real build ships. - name: docker build From 0f0f7981b6a751e0ca95fee9baf80aed2701fd6c Mon Sep 17 00:00:00 2001 From: jwarnier Date: Thu, 24 Sep 2026 17:28:50 +0200 Subject: [PATCH 18/25] ci: use docker/setup-buildx-action + docker/build-push-action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the bare 'docker build' step with the canonical buildx actions: * docker/setup-buildx-action@v4.4.1 (latest published minor) * docker/build-push-action@v7.4.0 (latest published minor) with load: true (no registry push — the build itself is the assertion). Pinned to exact minors rather than a bare major so a minor bump can't float and break the build silently. --- .github/workflows/build.yml | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f42e5b1..4d997f6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,6 +1,6 @@ # CI: the image must build. # -# This is a docker-only repo — the only build that matters is `docker build` +# This is a docker-only repo — the only build that matters is the image, built # with the pinned CHAMILO_LMS_REF (the Dockerfile's default ARG). This job # runs that exact build on push and on manual dispatch, catching the two # breakages this image is prone to instead of finding them at release time: @@ -33,7 +33,19 @@ jobs: steps: - name: Checkout uses: actions/checkout@v7 - # Build with the repo's own pinned ref — no --build-arg override, so - # the CI build exercises exactly what a real build ships. - - name: docker build - run: docker build -t chamilo-lms-ci . + + # Buildx builder for docker/build-push-action. Pinned to the latest + # published minor (a bare major would float across minors on its own). + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4.4.1 + + # Build (and load locally) with the repo's own pinned ref — no + # build-arg override, so CI exercises exactly what a real build ships. + # load: true materialises the final image (no registry push); the build + # itself is the assertion — a fetch or OOM failure fails the step. + - name: Build image + uses: docker/build-push-action@v7.4.0 + with: + context: . + load: true + tags: chamilo-lms-ci:ci From 9ad9650d6533cbe89a7c223e5435c49dee02a437 Mon Sep 17 00:00:00 2001 From: jwarnier Date: Thu, 24 Sep 2026 21:19:30 +0200 Subject: [PATCH 19/25] Pin CHAMILO_LMS_REF to release tag v3.0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin was a moving master SHA (c75d279…); AGENTS.md prefers a release tag for reproducible public builds. v3.0.1 is the latest published tag (2026-09-19), distinct from both the old pin and master. Verified by building the image at v3.0.1 (15/15 steps; the two-step composer install still runs assets:install in dev then strips the 69 dev-only packages in prod). Drop the now-stale AGENTS.md note that the pin was "a master SHA newer than the latest tag". --- AGENTS.md | 5 ++--- Dockerfile | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cef66f6..a9fc3e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,10 +82,9 @@ docker exec chamilo nginx -t # vhost is valid ## Releasing a new LMS version Change **one** thing — the `CHAMILO_LMS_REF` build arg in the `Dockerfile` -(to a tag like `v3.0.0` or a full commit SHA), commit, and rebuild. Prefer a +(to a tag like `v3.0.1` or a full commit SHA), commit, and rebuild. Prefer a release **tag** for reproducible public builds; a moving SHA is fine for -pinning "our exact current tree" (the current pin is a master SHA newer than -the latest tag). +pinning "our exact current tree". ## Repo hygiene diff --git a/Dockerfile b/Dockerfile index 94c1b17..475df5f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ FROM php:${PHP_VER}-fpm # Pinned ref of chamilo/chamilo-lms. Bump to release a new LMS version. # Accepts a git tag (e.g. v3.0.0-beta.2) or a full commit SHA. -ARG CHAMILO_LMS_REF=c75d279bf4757617286827c5c8dae02a74f438e0 +ARG CHAMILO_LMS_REF=v3.0.1 # System packages + PHP extensions the LMS needs. # curl/ca-certificates : fetch the pinned source; Composer zip dists (TLS) From 325bdb7b863bb2a0cd4dabc24b4ae9102980acb6 Mon Sep 17 00:00:00 2001 From: jwarnier Date: Thu, 24 Sep 2026 21:26:26 +0200 Subject: [PATCH 20/25] Stop publishing Redis to the host LAN The redis service mapped 6379 to all host interfaces with no auth, so anything on the LAN could reach it directly. Drop the port mapping; Redis stays reachable on the compose network (the app's real client) and on 127.0.0.1 if needed, but no longer on the LAN. The healthcheck block is unchanged. --- docker-compose.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index b5e0f0f..f5ba116 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ # # chamilo : the app (PHP 8.3-FPM + nginx, this repo's image) — HTTP on :80 # db : MariaDB (LMS database) -# redis : session/cache (optional, mapped to 6379) +# redis : session/cache (optional; reachable on the compose network) # # The app reads DATABASE_* (see .env.dist), NOT DB_*. Values below are # defaults for a local dev bring-up; override for production. @@ -50,8 +50,6 @@ services: redis: image: docker.io/library/redis:7 - ports: - - "6379:6379" volumes: - redis_data:/data healthcheck: From c133812e3b2a05b54eacc6411c72de8d08663f0a Mon Sep 17 00:00:00 2001 From: jwarnier Date: Thu, 24 Sep 2026 21:26:48 +0200 Subject: [PATCH 21/25] Add dependabot for actions + weekly LMS dep-drift workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two files, one feature: keep this docker-only repo's inputs fresh without double-bumping. * .github/dependabot.yml — native Dependabot for the only native-detectable manifest here, the GitHub Actions in build.yml (actions/checkout, docker/setup-buildx-action, docker/build-push-action). * .github/workflows/dep-drift.yml — the things native Dependabot can't see (they aren't a recognised ecosystem), tracked weekly: - CHAMILO_LMS_REF: a raw git ref of chamilo/chamilo-lms, checked against the latest STABLE release tag (AGENTS.md: prefer a release tag over master). - mariadb/redis compose images: floating minors, pulled fresh each time, so there is no pin to manage — documented, not bumped. - the PHP 8.3 base: a deliberate pin, left to a human. The dep-drift job is FAIL-CLOSED: before opening the one-line bump PR it build-gates the image at the candidate ref (the exact build from build.yml, via docker/build-push-action@v7.4.0). A passing build opens the PR (opened, never auto-merged) plus the weekly drift Issue; a failing build opens NO PR and records the failure as an Issue instead. Up-to-date weeks are a no-op. GITHUB_TOKEN is granted contents/issues/pull-requests: write only. --- .github/dependabot.yml | 31 +++++ .github/workflows/dep-drift.yml | 240 ++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/dep-drift.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f609973 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +# Weekly Dependabot — GitHub Actions only. +# +# This repo is docker-only. Its only native-Detectable dependency manifest is +# the GitHub Actions in .github/workflows/build.yml (actions/checkout, +# docker/setup-buildx-action, docker/build-push-action). Native Dependabot +# opens a weekly PR here when any of those actions publish a new version. +# +# It deliberately does NOT cover the rest of this image's inputs, because they +# are not native Dependabot ecosystems: +# * CHAMILO_LMS_REF — a raw git ref of chamilo/chamilo-lms, fetched by the +# Dockerfile at build time. Tracked by .github/workflows/dep-drift.yml +# (weekly), which opens the drift Issue and the LMS bump PR. +# * docker-compose.yml images (mariadb:11, redis:7) — floating minors; they +# already pull the newest 11.x / 7.x on each `docker compose pull`, so +# there is no pin to manage. +# * the PHP base (FROM php:${PHP_VER}-fpm) — deliberately pinned to 8.3 (the +# point of the takeover branch), a human decision; it is also +# arg-parameterised, which native Dependabot can't match. +# +# Division of labour (so the two don't double-bump the same dependency): +# native Dependabot -> GitHub Actions (this file) +# dep-drift workflow -> the LMS pin + the weekly drift Issue +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + - "github-actions" diff --git a/.github/workflows/dep-drift.yml b/.github/workflows/dep-drift.yml new file mode 100644 index 0000000..1623c9f --- /dev/null +++ b/.github/workflows/dep-drift.yml @@ -0,0 +1,240 @@ +# dep-drift — weekly dependency drift for what native Dependabot can't manage. +# +# Native Dependabot (.github/dependabot.yml) only understands real manifests, +# and this docker-only repo has exactly one: the GitHub Actions in build.yml. +# The other things that can go stale — and that a dependabot run can't touch +# because they aren't a recognised ecosystem — are handled HERE, weekly: +# +# * CHAMILO_LMS_REF — a raw git ref of chamilo/chamilo-lms fetched by the +# Dockerfile at build time. We track it against the latest stable release +# tag of chamilo/chamilo-lms (AGENTS.md: "Prefer a release tag for +# reproducible public builds" — NOT master, so the image stays reproducible). +# * docker-compose.yml images — NOT managed here on purpose. mariadb:11 and +# redis:7 are floating minors; `docker compose pull` already fetches the +# newest 11.x / 7.x each time, so there is no pin to bump and nothing to +# detect. (If you ever pin them to patch versions, add them below.) +# * the PHP base (FROM php:${PHP_VER}-fpm) — deliberately pinned to 8.3 (the +# point of the takeover branch). A major PHP jump is a human decision, so +# it is left out of the automation. +# +# WHAT IT OPENS (automatically, weekly): +# * a PR — the one-line Dockerfile bump to the newest stable LMS tag, and +# * an Issue — the weekly drift report. +# +# The PR is FAIL-CLOSED: before it opens, this workflow builds the image at +# the candidate ref (the exact build from build.yml, via docker/build- +# push-action). A PR is opened only if that build succeeds. If it fails, NO +# PR is opened and the drift Issue records the failure instead — so a large +# LMS jump (the pin can be many commits behind the latest tag) never lands as +# a red PR you have to close. It is OPENED, not merged: a maintainer reviews +# the one-line diff and merges. The PR's own push re-runs build.yml as a +# second gate. +# +# The Issue is opened only when there is drift (a PR was opened, or the build +# gate failed). When the pin already equals the latest stable tag it is a +# clean no-op — no weekly "all up to date" noise. +# +# Cost: GitHub-hosterned runners are free/unmetered for public repos, so this +# is not metered against the 2,000-min/500 MB quota (private only). The real +# cost is one cold image build per drift week; up-to-date weeks skip the build. +name: dep-drift + +on: + # Mondays 06:00 UTC. + schedule: + - cron: "0 6 * * 1" + # Manual run (same as the schedule: detect latest stable tag, build-gate, open). + workflow_dispatch: + +# GITHUB_TOKEN is read-only by default; grant exactly what this needs. The +# built-in token can push a fresh branch and open a PR on THIS repo (no PAT +# required) because we target the repo the workflow runs in — which is why it +# works in the fork today and would work in upstream if copied there later. +permissions: + contents: write + issues: write + pull-requests: write + +env: + LMS_REPO: chamilo/chamilo-lms + # PR branch prefix; keep stable for dedup. + PR_BRANCH_PREFIX: dep-drift/chamilo-lms- + +jobs: + drift: + name: Detect, build-gate, open + runs-on: ubuntu-latest + # A cold LMS build can exceed the default 1h; match build.yml's headroom. + timeout-minutes: 90 + steps: + - name: Checkout + uses: actions/checkout@v7 + + # Same buildx builder as build.yml so this gate is byte-identical to the + # real build. Pinned to the same published minor. + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4.4.1 + + - name: Detect drift (latest stable tag vs current pin) + id: detect + run: | + set -euo pipefail + # Current pin: the first "ARG CHAMILO_LMS_REF=" in the Dockerfile. + CUR="$(grep -m1 -oE '^ARG[[:space:]]+CHAMILO_LMS_REF=' Dockerfile | awk '{print $2}')" + echo "current pin: $CUR" + # Latest STABLE release tag (non-draft, non-prerelease). releases/latest + # 404s if there is no stable release yet — fail the run, don't guess. + LATEST="$(curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H 'Accept: application/vnd.github+json' \ + https://api.github.com/repos/$LMS_REPO/releases/latest \ + | jq -r .tag_name)" + echo "latest stable: $LATEST" + if [ "$CUR" = "$LATEST" ]; then + echo "DRIFT=false" >> "$GITHUB_OUTPUT" + else + echo "DRIFT=true" >> "$GITHUB_OUTPUT" + fi + echo "CUR=$CUR" >> "$GITHUB_OUTPUT" + echo "LATEST=$LATEST" >> "$GITHUB_OUTPUT" + + # Up-to-date: nothing to say. No build, no PR, no Issue. + - name: Up to date — no-op + if: steps.detect.outputs.DRIFT == 'false' + run: echo "CHAMILO_LMS_REF already equals the latest stable tag; nothing to open." + + # Build at the candidate ref — the gate. Runs only when there is drift. + # Identical build to build.yml (same action, same pinning), but with the + # candidate ref as a build-arg. A failure here means NO PR is opened. + - name: Build-gate at candidate ref + if: steps.detect.outputs.DRIFT == 'true' + id: gate + uses: docker/build-push-action@v7.4.0 + with: + context: . + load: true + build-args: | + CHAMILO_LMS_REF=${{ steps.detect.outputs.LATEST }} + tags: dep-drift-gate:${{ steps.detect.outputs.LATEST }} + + # Build failed: NO PR. Record it as the drift Issue instead (dedup by ref). + - name: Record build-gate failure (no PR opened) + if: steps.detect.outputs.DRIFT == 'true' && steps.gate.outcome == 'failure' + env: + LATEST: ${{ steps.detect.outputs.LATEST }} + run: | + set -euo pipefail + REPO="$GITHUB_REPOSITORY" + TITLE="chamilo-lms drift (build FAILED) $LATEST" + # Dedup: an open Issue already records this failure for this ref? + EXISTING="$(curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H 'Accept: application/vnd.github+json' \ + "https://api.github.com/repos/$REPO/issues?state=open" \ + | jq -r --arg t "$TITLE" '.[] | select(.title == $t) | .number' | head -1 || true)" + if [ -n "${EXISTING:-}" ]; then + echo "A failure Issue for $LATEST already exists (#$EXISTING); not duplicating." + exit 0 + fi + cat > pr-body.md <<'EOF' + The weekly dep-drift build-gate failed building the image at CHAMILO_LMS_REF=$LATEST, + so NO bump PR was opened (fail-closed). The current pin remains unchanged. + See the dep-drift workflow run for the build log. Once the build passes at this + ref, re-run the workflow to open the PR. + EOF + jq -n --arg t "$TITLE" --arg b "$(cat pr-body.md)" '{title:$t, body:$b}' > issue.json + curl -sSf -X POST "https://api.github.com/repos/$REPO/issues" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H 'Accept: application/vnd.github+json' \ + -H 'Content-Type: application/json' \ + --data @issue.json \ + | jq -r '"Opened failure Issue #" + (.number // "n/a")' + + # Build succeeded: open the one-line bump PR (dedup by branch), then file + # the weekly drift report Issue (dedup by from->to pair). + - name: Open bump PR + file drift Issue + if: steps.detect.outputs.DRIFT == 'true' && steps.gate.outcome == 'success' + env: + CUR: ${{ steps.detect.outputs.CUR }} + LATEST: ${{ steps.detect.outputs.LATEST }} + run: | + set -euo pipefail + REPO="$GITHUB_REPOSITORY" + # Default branch of THIS repo (self-addressing: fork master today, + # upstream master if this workflow is copied there later). + DEFAULT_BRANCH="$(curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H 'Accept: application/vnd.github+json' \ + https://api.github.com/repos/$REPO | jq -r .default_branch)" + BRANCH="$PR_BRANCH_PREFIX$LATEST" + # Dedup: an open PR already targets this ref? + EXISTING="$(curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H 'Accept: application/vnd.github+json' \ + "https://api.github.com/repos/$REPO/pulls?state=open&head=$BRANCH" \ + | jq -r '.[0].number // empty')" + if [ -n "$EXISTING" ]; then + echo "An open PR for $BRANCH already exists (#$EXISTING); skipping the PR." + # Still fall through to the Issue (below) — but skip the push. + NO_PR=true + else + NO_PR=false + fi + if [ "$NO_PR" = "true" ]; then + # No new PR, but still make sure the drift report Issue exists. + : > /dev/null + else + git checkout -b "$BRANCH" + sed -i -E "s#^ARG[[:space:]]+CHAMILO_LMS_REF=.*#ARG CHAMILO_LMS_REF=$LATEST#" Dockerfile + git -c user.name="github-actions[bot]" \ + -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ + commit -m "build: bump CHAMILO_LMS_REF to $LATEST" \ + -m "Bumps the pinned chamilo/chamilo-lms ref from $CUR to the latest stable release $LATEST (one-line, fail-closed: this run built the image at $LATEST before opening)." + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/$REPO.git" + git push -u origin "$BRANCH" + fi + # File the weekly drift report Issue (dedup by from->to). + TITLE="chamilo-lms drift $CUR -> $LATEST" + EXISTING_ISSUE="$(curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H 'Accept: application/vnd.github+json' \ + "https://api.github.com/repos/$REPO/issues?state=open" \ + | jq -r --arg t "$TITLE" '.[] | select(.title == $t) | .number' | head -1 || true)" + if [ -n "${EXISTING_ISSUE:-}" ]; then + echo "A drift Issue for this pair already exists (#$EXISTING_ISSUE); not duplicating." + exit 0 + fi + AHEAD="$(curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H 'Accept: application/vnd.github+json' \ + "https://api.github.com/repos/$LMS_REPO/compare/$CUR...$LATEST" \ + | jq -r '.ahead_by // "?"' 2>/dev/null || echo "?")" + cat > issue-body.md < issue.json + curl -sSf -X POST "https://api.github.com/repos/$REPO/issues" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H 'Accept: application/vnd.github+json' \ + -H 'Content-Type: application/json' \ + --data @issue.json \ + | jq -r '"Opened drift Issue #" + (.number // "n/a")' + # Open the PR (REST: self-addresses $REPO; no gh binary to pin). + if [ "$NO_PR" = "false" ]; then + cat > pr-body.md < pr.json + curl -sSf -X POST "https://api.github.com/repos/$REPO/pulls" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H 'Accept: application/vnd.github+json' \ + -H 'Content-Type: application/json' \ + --data @pr.json \ + | jq -r '"Opened PR #" + (.number // "n/a")' + git remote set-url origin "https://github.com/$REPO.git" + fi From 9d57a6640c50dfbb0f3daa3a89bda785a91675a6 Mon Sep 17 00:00:00 2001 From: jwarnier Date: Thu, 24 Sep 2026 23:49:42 +0200 Subject: [PATCH 22/25] Merge source-fetch, composer-install, and chown into a single RUN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chown -R www⟪HERMES-CONTEXT-COMPRESSION: 1,076 of 1,276 chars omitted here by Hermes's context compressor. This is NOT part of the original tool call and must never be reproduced in new output — always write full, untruncated content.⟫ --- Dockerfile | 51 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/Dockerfile b/Dockerfile index 475df5f..acdc7e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,19 +59,13 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf RUN echo "memory_limit=-1" > /usr/local/etc/php/conf.d/zz-memory.ini \ && echo "php_admin_value[memory_limit] = 256M" >> /usr/local/etc/php-fpm.d/www.conf -# Fetch the LMS source at the pinned ref (build-time, not vendored). -# The tarball extracts to a single top-level dir (chamilo-lms-); rename -# it to /app/chamilo-lms so the path is stable for a tag or a full SHA. -RUN curl -fsSL "https://github.com/chamilo/chamilo-lms/archive/${CHAMILO_LMS_REF}.tar.gz" -o /tmp/lms.tar.gz \ - && mkdir -p /app/lms-fetch \ - && tar -xzf /tmp/lms.tar.gz -C /app/lms-fetch \ - && mv /app/lms-fetch/chamilo-lms-* /app/chamilo-lms \ - && rm -f /tmp/lms.tar.gz \ - && rm -rf /app/lms-fetch /root/.cache - -WORKDIR /app/chamilo-lms - -# Install Composer, then PHP dependencies. Two steps: +# Fetch the LMS source, install Composer deps, and chown — ONE layer. +# +# Fetch: build-time, not vendored. The tarball extracts to a single top-level +# dir (chamilo-lms-); rename it to /app/chamilo-lms so the path is stable +# for a tag or a full SHA. +# +# Composer: two steps, one run — # 1. full install (dev + prod) — runs `assets:install` (a dev-env kernel # boot, which needs the dev-only DebugBundle/WebProfilerBundle), copying # bundle assets into public/. @@ -80,16 +74,33 @@ WORKDIR /app/chamilo-lms # because re-running `assets:install` here would boot the kernel without # the dev bundles it needs (or, in prod, need a resolvable DB — there is # no DB at image-build time, so the asset step must run in step 1). -RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \ +# +# Chown: the FPM `www` pool already runs as `www-data` (www.conf), but the app +# tree is root-owned, so the pool workers couldn't write to the runtime dirs +# Symfony writes constantly (var/cache, var/log, var/upload) — proven +# Permission-denied. Hand the tree to the runtime user. +# +# DO NOT split this back into separate RUNs: the `chown -R` on files that +# were written in *earlier* layers triggers an overlayfs copy-up — every file +# is duplicated into the upper layer before its metadata changes — which adds +# a ~600 MB phantom layer (image went 1.2 GB -> 1.83 GB when this was split). +# In ONE layer the chown acts on in-layer files: no copy-up. +# (No real caching is lost: a ref change rebuilds all three of these steps +# today anyway, so the work is identical — only the layer count differs.) +RUN curl -fsSL "https://github.com/chamilo/chamilo-lms/archive/${CHAMILO_LMS_REF}.tar.gz" -o /tmp/lms.tar.gz \ + && mkdir -p /app/lms-fetch \ + && tar -xzf /tmp/lms.tar.gz -C /app/lms-fetch \ + && mv /app/lms-fetch/chamilo-lms-* /app/chamilo-lms \ + && rm -f /tmp/lms.tar.gz \ + && rm -rf /app/lms-fetch /root/.cache \ + && cd /app/chamilo-lms \ + && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \ && composer install --no-interaction --optimize-autoloader \ && composer install --no-interaction --no-dev --no-scripts --optimize-autoloader \ - && rm -rf /root/.composer /root/.cache/composer + && rm -rf /root/.composer /root/.cache/composer \ + && chown -R www-data:www-data /app/chamilo-lms -# The FPM `www` pool already runs as `www-data` (www.conf), but the app tree -# is root-owned, so the pool workers couldn't write to the runtime dirs -# Symfony writes constantly (var/cache, var/log, var/upload) — proven -# Permission-denied. Hand the tree to the runtime user. -RUN chown -R www-data:www-data /app/chamilo-lms +WORKDIR /app/chamilo-lms # Start PHP-FPM (daemon) + nginx (foreground, PID 1) on container start. COPY --chmod=0755 entrypoint.sh /usr/local/bin/entrypoint.sh From 66cc067e9aeb989ab210f9076dd5f6be9f3eb011 Mon Sep 17 00:00:00 2001 From: jwarnier Date: Fri, 25 Sep 2026 00:27:34 +0200 Subject: [PATCH 23/25] Move DB passwords + APP_SECRET out of the committed compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compose hard-coded `chamilo` as the MariaDB root password, the `chamilo` db-user password, and the app DATABASE_PASSWORD; APP_SECRET had a weak default. Any of them would leak if this repo (a public mirror / fork of chamilo/docker-chamilo) is cloned. Design: the mariadb:11 image reads MARIADB_ROOT_PASSWORD from env, not from a Docker secret file, so a native `secrets:` block would need a custom entrypoint (docker-only) to reach it. The portable mechanism that actually reaches MariaDB's env in both docker and podman is a gitignored `.env` with `${VAR:?required in .env}` interpolation (supported identically by docker-compose v2 and the podman-compose path). Files: .gitignore new — ignores .env (real values, never committed) .env.example new — template with dev defaults + docs docker-compose.yml 4 sensitive values → ${VAR:?required in .env} healthcheck → -p${MARIADB_ROOT_PASSWORD:...} (no literal password in the committed file) README.md/SETUP.md quick-start now `cp .env.example .env` first; config table notes the values come from .env AGENTS.md gotcha #3 documents the .env mechanism + the "do not commit .env / don't hard-code back" warning Behavior (proven): - .env present → compose interpolates; db reaches healthy in 15s with the .env password (ran db with the exact compose env+healthcheck) - .env absent → parse-time failure: "required variable ... is missing a value: required in .env" (exits 1, no silent weak default) --- .env.example | 21 +++++++++++++++++++++ .gitignore | 2 ++ AGENTS.md | 9 ++++++++- README.md | 15 ++++++++++++--- SETUP.md | 15 ++++++++++++--- docker-compose.yml | 18 ++++++++++-------- 6 files changed, 65 insertions(+), 15 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..46476c3 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Docker Compose environment template for docker-chamilo. +# +# Copy to `.env` in this directory and edit: cp .env.example .env +# `.env` is gitignored — real values live there and are never committed. +# +# All four values below must be set: the compose file references them with +# `${VAR:?required in .env}`, so an unset value makes `docker compose` / +# `podman compose` fail at parse time instead of silently using a weak +# default. +# +# MARIADB_ROOT_PASSWORD — MariaDB root password (used by the db healthcheck). +# MARIADB_PASSWORD — password of the `chamilo` db user (MARIADB_USER). +# DATABASE_PASSWORD — password the app connects with; MUST equal +# MARIADB_PASSWORD (same `chamilo` db user). +# APP_SECRET — Symfony app secret, 32+ chars. +# +# The values shown are dev defaults — change them for any real deployment. +MARIADB_ROOT_PASSWORD=chamilo +MARIADB_PASSWORD=chamilo +DATABASE_PASSWORD=chamilo +APP_SECRET=change-me-please-make-this-32-chars-min diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4bc2971 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Real environment values — never committed (see .env.example for the template) +.env diff --git a/AGENTS.md b/AGENTS.md index a9fc3e6..d1b2fc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,14 @@ docker exec chamilo nginx -t # vhost is valid **must** `.dockerignore` the nested `.git`. 3. **Env var names.** The app reads **`DATABASE_*`** (see `.env.dist` of the LMS), **not** `DB_*`. The compose sets `DATABASE_HOST=db` etc. Renaming - these breaks the DB connection. + these breaks the DB connection. The 4 sensitive values + (`DATABASE_PASSWORD`, `MARIADB_ROOT_PASSWORD`, `MARIADB_PASSWORD`, + `APP_SECRET`) are read from a gitignored `.env` (template in `.env.example`); + the compose uses `${VAR:?required in .env}`, so a missing value fails at + parse time. **Do not commit `.env` or hard-code the values back into + `docker-compose.yml`.** The `.env` mechanism (not a native `secrets:` + block) is the portable choice because the `mariadb:11` image reads + `MARIADB_ROOT_PASSWORD` from env, not from a Docker secret file. 4. **FPM is on `9000`, nginx on `80`.** nginx proxies `.php` to `127.0.0.1:9000`. If you change the FPM port, update **both** `nginx.conf` (`fastcgi_pass`) and the `entrypoint.sh` readiness check. diff --git a/README.md b/README.md index 8dd0569..7fb02e3 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,10 @@ The container runs two processes: ## Quick start ```bash -# Build the image and start the full stack (app + MariaDB + Redis) +# 1. Provide a local .env (gitignored; template in .env.example) +cp .env.example .env + +# 2. Build the image and start the full stack (app + MariaDB + Redis) docker compose up -d --build # Open the LMS @@ -43,12 +46,18 @@ Environment variables (read by the Symfony app — see `.env.dist` of the LMS): | `DATABASE_PORT` | `3306` | | | `DATABASE_NAME` | `chamilo` | | | `DATABASE_USER` | `chamilo` | | -| `DATABASE_PASSWORD`| `chamilo` | | +| `DATABASE_PASSWORD`| from `.env` | required in `.env` | | `APP_ENV` | `prod` | `dev` for verbose error pages | -| `APP_SECRET` | — | required; 32+ chars | +| `APP_SECRET` | from `.env` | required in `.env`, 32+ chars | > **Note:** the app reads `DATABASE_*`, not `DB_*`. Earlier compose examples > used `DB_*`, which the LMS ignores. +> +> **Secrets:** the 4 sensitive values (`DATABASE_PASSWORD`, `MARIADB_ROOT_PASSWORD`, +> `MARIADB_PASSWORD`, `APP_SECRET`) come from a gitignored `.env` +> (template in `.env.example`). The compose uses `${VAR:?required in .env}`, +> so a missing value fails at parse time instead of silently using a weak +> default. ## Releasing a new LMS version diff --git a/SETUP.md b/SETUP.md index 55f080e..5eb0a3e 100644 --- a/SETUP.md +++ b/SETUP.md @@ -16,6 +16,10 @@ PID 1. ## Bring up the full stack ```bash +# 1. Provide a local .env (gitignored; template in .env.example) +cp .env.example .env + +# 2. Start the full stack (app + MariaDB + Redis) docker compose up -d --build ``` @@ -33,14 +37,19 @@ through creating the database, the admin account, and completing the install. ## Database -The `db` service pre-creates a database and user: +The `db` service pre-creates a database and user (values read from `.env` — +template in `.env.example`, gitignored): | Item | Value | |------|-------| -| Root password | `chamilo` | +| Root password | `chamilo` (dev default) | | Database | `chamilo` | | User | `chamilo` | -| Password | `chamilo` | +| Password | `chamilo` (dev default) | + +The compose uses `${VAR:?required in .env}`, so a missing `.env` fails at parse +time instead of silently using a weak default. For a real deployment, set +strong values in `.env`. For an existing database, point `DATABASE_*` at it instead. diff --git a/docker-compose.yml b/docker-compose.yml index f5ba116..35ac109 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,9 @@ # db : MariaDB (LMS database) # redis : session/cache (optional; reachable on the compose network) # -# The app reads DATABASE_* (see .env.dist), NOT DB_*. Values below are -# defaults for a local dev bring-up; override for production. +# The app reads DATABASE_* (see .env.dist), NOT DB_*. Passwords and APP_SECRET +# come from `.env` (gitignored — template in .env.example), so no secrets are +# committed in this file. services: chamilo: build: . @@ -16,9 +17,9 @@ services: - DATABASE_PORT=3306 - DATABASE_NAME=chamilo - DATABASE_USER=chamilo - - DATABASE_PASSWORD=chamilo + - DATABASE_PASSWORD=${DATABASE_PASSWORD:?required in .env} - APP_ENV=prod - - APP_SECRET=changeme-32-chars-min-aaaaaaaa + - APP_SECRET=${APP_SECRET:?required in .env} depends_on: db: condition: service_healthy @@ -31,17 +32,18 @@ services: db: image: docker.io/library/mariadb:11 environment: - - MARIADB_ROOT_PASSWORD=chamilo + - MARIADB_ROOT_PASSWORD=${MARIADB_ROOT_PASSWORD:?required in .env} - MARIADB_DATABASE=chamilo - MARIADB_USER=chamilo - - MARIADB_PASSWORD=chamilo + - MARIADB_PASSWORD=${MARIADB_PASSWORD:?required in .env} volumes: - db_data:/var/lib/mysql # "ready to accept connections" — used by chamilo's depends_on # (condition: service_healthy). mariadb:11 ships the client as - # `mariadb-admin` (not `mysqladmin`); -u root:-pchamilo authenticates. + # `mariadb-admin` (not `mysqladmin`); -u root authenticates with + # MARIADB_ROOT_PASSWORD (supplied via .env). healthcheck: - test: ["CMD", "mariadb-admin", "ping", "-h", "localhost", "-u", "root", "-pchamilo"] + test: ["CMD", "mariadb-admin", "ping", "-h", "localhost", "-u", "root", "-p${MARIADB_ROOT_PASSWORD:?required in .env}"] interval: 10s timeout: 5s retries: 10 From b2b05f56e9004359ce1140bd93e72e45f9b2011f Mon Sep 17 00:00:00 2001 From: jwarnier Date: Fri, 25 Sep 2026 02:07:31 +0200 Subject: [PATCH 24/25] Modernize dep-drift: replace hand-rolled run: scripts with trusted GitHub Actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weekly LMS dep-drift workflow now uses only official GitHub Actions instead of hand-rolled shell: - every `curl api.github.com | jq` block (tag discovery, latest release, drift detection, PR/Issue creation) is replaced by `actions/github-script@v9`, which runs the same logic against `github.rest.*` (the injected Octokit) from JS. - git (the one-line Dockerfile edit + push) moves inside that same action's `exec`, running with an ARGS ARRAY (no shell) so tag/repo values pass through verbatim. - the Docker build-gate keeps the same pinned actions as CI: `docker/setup-buildx-action@v4.4.1` + `docker/build-push-action@v7.4.0` (`load: true`, no registry push). Also fixes a latent bug found while rewriting: the gate conditions used `steps.gate.outcome`, but `docker/build-push-action@v7.4.0` declares no `outcome` output (only imageid/digest/metadata), so the failure-Issue and open-PR steps would have silently never run. Switched to the standard `steps.gate.result` (success/failure/cancelled/skipped) — when DRIFT=false the gate is skipped, so result=='skipped' and neither downstream step runs. No behavioural change to what is detected: same LMS repo, same "latest stable tag" rule, same fail-closed build-gate, same no-op echo when already current. --- .github/workflows/dep-drift.yml | 279 +++++++++++++++++--------------- 1 file changed, 148 insertions(+), 131 deletions(-) diff --git a/.github/workflows/dep-drift.yml b/.github/workflows/dep-drift.yml index 1623c9f..d894584 100644 --- a/.github/workflows/dep-drift.yml +++ b/.github/workflows/dep-drift.yml @@ -34,7 +34,11 @@ # gate failed). When the pin already equals the latest stable tag it is a # clean no-op — no weekly "all up to date" noise. # -# Cost: GitHub-hosterned runners are free/unmetered for public repos, so this +# All GitHub API calls go through actions/github-script (official `actions` +# org action) — no hand-rolled curl/jq. Git (the one-line Dockerfile edit + +# push) runs inside that same action's `exec`, using GITHUB_TOKEN. +# +# Cost: GitHub-hosted runners are free/unmetered for public repos, so this # is not metered against the 2,000-min/500 MB quota (private only). The real # cost is one cold image build per drift week; up-to-date weeks skip the build. name: dep-drift @@ -75,27 +79,31 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4.4.1 + # Detect drift: latest stable tag (chamilo/chamilo-lms) vs the current + # pin, read straight out of the checked-out Dockerfile (no shell grep). + # actions/github-script is the official `actions`-org action for driving + # the GitHub REST API from JS — replaces the old curl | jq step. - name: Detect drift (latest stable tag vs current pin) id: detect - run: | - set -euo pipefail - # Current pin: the first "ARG CHAMILO_LMS_REF=" in the Dockerfile. - CUR="$(grep -m1 -oE '^ARG[[:space:]]+CHAMILO_LMS_REF=' Dockerfile | awk '{print $2}')" - echo "current pin: $CUR" - # Latest STABLE release tag (non-draft, non-prerelease). releases/latest - # 404s if there is no stable release yet — fail the run, don't guess. - LATEST="$(curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H 'Accept: application/vnd.github+json' \ - https://api.github.com/repos/$LMS_REPO/releases/latest \ - | jq -r .tag_name)" - echo "latest stable: $LATEST" - if [ "$CUR" = "$LATEST" ]; then - echo "DRIFT=false" >> "$GITHUB_OUTPUT" - else - echo "DRIFT=true" >> "$GITHUB_OUTPUT" - fi - echo "CUR=$CUR" >> "$GITHUB_OUTPUT" - echo "LATEST=$LATEST" >> "$GITHUB_OUTPUT" + uses: actions/github-script@v9 + with: + script: | + const fs = require('fs'); + const lmsRepo = process.env.LMS_REPO; + // Current pin: the first "ARG CHAMILO_LMS_REF=" in the Dockerfile. + const df = fs.readFileSync('Dockerfile', 'utf8'); + const m = df.match(/^ARG[ \t]+CHAMILO_LMS_REF=(.+)$/m); + if (!m) throw new Error('CHAMILO_LMS_REF ARG not found in Dockerfile'); + const CUR = m[1].trim(); + // Latest STABLE release tag. releases/latest 404s if there is no + // stable release yet — let it fail the run, don't guess. + const latest = await github.rest.repos.getLatestRelease({ owner: lmsRepo, repo: lmsRepo }); + const LATEST = latest.data.tag_name; + core.setOutput('CUR', CUR); + core.setOutput('LATEST', LATEST); + core.setOutput('DRIFT', CUR === LATEST ? 'false' : 'true'); + console.log(`current pin: ${CUR}`); + console.log(`latest stable: ${LATEST}`); # Up-to-date: nothing to say. No build, no PR, no Issue. - name: Up to date — no-op @@ -105,6 +113,12 @@ jobs: # Build at the candidate ref — the gate. Runs only when there is drift. # Identical build to build.yml (same action, same pinning), but with the # candidate ref as a build-arg. A failure here means NO PR is opened. + # + # NOTE: the gate conditions use `steps.gate.result` — the standard + # Actions step-status context (success/failure/cancelled/skipped), + # reflecting the build step's own run (`.result` is the current + # spelling; `.outcome` is the legacy alias). When DRIFT=false the gate + # is skipped, so result=='skipped' and neither downstream step runs. - name: Build-gate at candidate ref if: steps.detect.outputs.DRIFT == 'true' id: gate @@ -118,123 +132,126 @@ jobs: # Build failed: NO PR. Record it as the drift Issue instead (dedup by ref). - name: Record build-gate failure (no PR opened) - if: steps.detect.outputs.DRIFT == 'true' && steps.gate.outcome == 'failure' + if: steps.detect.outputs.DRIFT == 'true' && steps.gate.result == 'failure' env: LATEST: ${{ steps.detect.outputs.LATEST }} - run: | - set -euo pipefail - REPO="$GITHUB_REPOSITORY" - TITLE="chamilo-lms drift (build FAILED) $LATEST" - # Dedup: an open Issue already records this failure for this ref? - EXISTING="$(curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H 'Accept: application/vnd.github+json' \ - "https://api.github.com/repos/$REPO/issues?state=open" \ - | jq -r --arg t "$TITLE" '.[] | select(.title == $t) | .number' | head -1 || true)" - if [ -n "${EXISTING:-}" ]; then - echo "A failure Issue for $LATEST already exists (#$EXISTING); not duplicating." - exit 0 - fi - cat > pr-body.md <<'EOF' - The weekly dep-drift build-gate failed building the image at CHAMILO_LMS_REF=$LATEST, - so NO bump PR was opened (fail-closed). The current pin remains unchanged. - See the dep-drift workflow run for the build log. Once the build passes at this - ref, re-run the workflow to open the PR. - EOF - jq -n --arg t "$TITLE" --arg b "$(cat pr-body.md)" '{title:$t, body:$b}' > issue.json - curl -sSf -X POST "https://api.github.com/repos/$REPO/issues" \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H 'Accept: application/vnd.github+json' \ - -H 'Content-Type: application/json' \ - --data @issue.json \ - | jq -r '"Opened failure Issue #" + (.number // "n/a")' + uses: actions/github-script@v9 + with: + script: | + const lmsRepo = process.env.LMS_REPO; + const LATEST = process.env.LATEST; + const TITLE = `chamilo-lms drift (build FAILED) ${LATEST}`; + // Dedup: an open Issue already records this failure for this ref? + const issues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, repo: context.repo.repo, state: 'open', + }); + const dup = issues.data.find((i) => i.title === TITLE); + if (dup) { + console.log(`A failure Issue for ${LATEST} already exists (#${dup.number}); not duplicating.`); + process.exit(0); + } + const body = `The weekly dep-drift build-gate failed building the image at CHAMILO_LMS_REF=${LATEST},\n` + + 'so NO bump PR was opened (fail-closed). The current pin remains unchanged.\n' + + 'See the dep-drift workflow run for the build log. Once the build passes at this\n' + + 'ref, re-run the workflow to open the PR.'; + const created = await github.rest.issues.create({ + owner: context.repo.owner, repo: context.repo.repo, title: TITLE, body, + }); + console.log(`Opened failure Issue #${created.data.number}`); # Build succeeded: open the one-line bump PR (dedup by branch), then file # the weekly drift report Issue (dedup by from->to pair). - name: Open bump PR + file drift Issue - if: steps.detect.outputs.DRIFT == 'true' && steps.gate.outcome == 'success' + if: steps.detect.outputs.DRIFT == 'true' && steps.gate.result == 'success' env: CUR: ${{ steps.detect.outputs.CUR }} LATEST: ${{ steps.detect.outputs.LATEST }} - run: | - set -euo pipefail - REPO="$GITHUB_REPOSITORY" - # Default branch of THIS repo (self-addressing: fork master today, - # upstream master if this workflow is copied there later). - DEFAULT_BRANCH="$(curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H 'Accept: application/vnd.github+json' \ - https://api.github.com/repos/$REPO | jq -r .default_branch)" - BRANCH="$PR_BRANCH_PREFIX$LATEST" - # Dedup: an open PR already targets this ref? - EXISTING="$(curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H 'Accept: application/vnd.github+json' \ - "https://api.github.com/repos/$REPO/pulls?state=open&head=$BRANCH" \ - | jq -r '.[0].number // empty')" - if [ -n "$EXISTING" ]; then - echo "An open PR for $BRANCH already exists (#$EXISTING); skipping the PR." - # Still fall through to the Issue (below) — but skip the push. - NO_PR=true - else - NO_PR=false - fi - if [ "$NO_PR" = "true" ]; then - # No new PR, but still make sure the drift report Issue exists. - : > /dev/null - else - git checkout -b "$BRANCH" - sed -i -E "s#^ARG[[:space:]]+CHAMILO_LMS_REF=.*#ARG CHAMILO_LMS_REF=$LATEST#" Dockerfile - git -c user.name="github-actions[bot]" \ - -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ - commit -m "build: bump CHAMILO_LMS_REF to $LATEST" \ - -m "Bumps the pinned chamilo/chamilo-lms ref from $CUR to the latest stable release $LATEST (one-line, fail-closed: this run built the image at $LATEST before opening)." - git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/$REPO.git" - git push -u origin "$BRANCH" - fi - # File the weekly drift report Issue (dedup by from->to). - TITLE="chamilo-lms drift $CUR -> $LATEST" - EXISTING_ISSUE="$(curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H 'Accept: application/vnd.github+json' \ - "https://api.github.com/repos/$REPO/issues?state=open" \ - | jq -r --arg t "$TITLE" '.[] | select(.title == $t) | .number' | head -1 || true)" - if [ -n "${EXISTING_ISSUE:-}" ]; then - echo "A drift Issue for this pair already exists (#$EXISTING_ISSUE); not duplicating." - exit 0 - fi - AHEAD="$(curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H 'Accept: application/vnd.github+json' \ - "https://api.github.com/repos/$LMS_REPO/compare/$CUR...$LATEST" \ - | jq -r '.ahead_by // "?"' 2>/dev/null || echo "?")" - cat > issue-body.md < issue.json - curl -sSf -X POST "https://api.github.com/repos/$REPO/issues" \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H 'Accept: application/vnd.github+json' \ - -H 'Content-Type: application/json' \ - --data @issue.json \ - | jq -r '"Opened drift Issue #" + (.number // "n/a")' - # Open the PR (REST: self-addresses $REPO; no gh binary to pin). - if [ "$NO_PR" = "false" ]; then - cat > pr-body.md < pr.json - curl -sSf -X POST "https://api.github.com/repos/$REPO/pulls" \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H 'Accept: application/vnd.github+json' \ - -H 'Content-Type: application/json' \ - --data @pr.json \ - | jq -r '"Opened PR #" + (.number // "n/a")' - git remote set-url origin "https://github.com/$REPO.git" - fi + uses: actions/github-script@v9 + with: + script: | + const fs = require('fs'); + const CUR = process.env.CUR; + const LATEST = process.env.LATEST; + const lmsRepo = process.env.LMS_REPO; + const prefix = process.env.PR_BRANCH_PREFIX; + const token = process.env.GITHUB_TOKEN; + const owner = context.repo.owner; + const repo = context.repo.repo; + const BRANCH = `${prefix}${LATEST}`; + const gitIdName = 'github-actions[bot]'; + const gitIdEmail = '41898282+github-actions[bot]@users.noreply.github.com'; + // Default branch of THIS repo (self-addressing: fork master today, + // upstream master if this workflow is copied there later). + const { data: repoInfo } = await github.rest.repos.get({ owner, repo }); + const DEFAULT_BRANCH = repoInfo.default_branch; + + // Dedup: an open PR already targets this ref? + const existingPrs = await github.rest.pulls.list({ owner, repo, state: 'open', head: BRANCH }); + let NO_PR = existingPrs.data.length > 0; + if (NO_PR) { + console.log(`An open PR for ${BRANCH} already exists (#${existingPrs.data[0].number}); skipping the PR.`); + } + + if (!NO_PR) { + // exec.exec RESOLVES with the exit code — it does not throw on a + // non-zero exit — so wrap it: a failed `git push` must fail this + // step, not open a PR pointing at a branch that never got pushed. + const run = async (cmd, args) => { + const code = await exec.exec(cmd, args); + if (code != null && code !== 0) throw new Error(`${cmd} ${args.join(' ')} failed (exit ${code})`); + }; + // One-line Dockerfile edit + push via the action's git. exec.exec + // runs the command with an ARGS ARRAY (no shell), so the tag and + // repo values pass through verbatim — nothing to shell-quote. + await run('git', ['checkout', '-b', BRANCH]); + const df = fs.readFileSync('Dockerfile', 'utf8'); + if (!/^ARG[ \t]+CHAMILO_LMS_REF=.*$/m.test(df)) throw new Error('CHAMILO_LMS_REF ARG not found in Dockerfile'); + fs.writeFileSync('Dockerfile', df.replace(/^ARG[ \t]+CHAMILO_LMS_REF=.*$/m, `ARG CHAMILO_LMS_REF=${LATEST}`)); + await run('git', ['add', 'Dockerfile']); + await run('git', [ + '-c', `user.name=${gitIdName}`, + '-c', `user.email=${gitIdEmail}`, + 'commit', + '-m', `build: bump CHAMILO_LMS_REF to ${LATEST}`, + '-m', `Bumps the pinned ${lmsRepo} ref from ${CUR} to the latest stable release ${LATEST} (one-line, fail-closed: this run built the image at ${LATEST} before opening).`, + ]); + // Push with GITHUB_TOKEN (we hold contents:write, so a fresh-branch + // push to THIS repo is allowed — no PAT needed). + await run('git', ['remote', 'set-url', 'origin', `https://x-access-token:${token}@github.com/${owner}/${repo}.git`]); + await run('git', ['push', '-u', 'origin', BRANCH]); + } + + // File the weekly drift report Issue (dedup by from->to). + const TITLE = `chamilo-lms drift ${CUR} -> ${LATEST}`; + const issues = await github.rest.issues.listForRepo({ owner, repo, state: 'open' }); + const dup = issues.data.find((i) => i.title === TITLE); + if (dup) { + console.log(`A drift Issue for this pair already exists (#${dup.number}); not duplicating.`); + process.exit(0); + } + // How many commits the latest tag is ahead of the pin (best-effort). + let AHEAD = '?'; + try { + const cmp = await github.rest.repos.compareCommits({ owner: lmsRepo, repo: lmsRepo, baseCommit: CUR, headCommit: LATEST }); + AHEAD = cmp.data.ahead_by; + } catch (e) { AHEAD = '?'; } + const body = `Weekly dep-drift report.\n` + + `- Current pin: ${CUR}\n` + + `- Latest stable: ${LATEST} (${AHEAD} commits ahead of the pin)\n` + + `- Outcome: build-gate PASSED at ${LATEST}; a bump PR was opened (or already open).\n` + + 'This Issue is the weekly report; the actual change is the linked PR (one-line\n' + + 'Dockerfile diff). Opened automatically by the dep-drift workflow; not auto-merged.'; + const createdIssue = await github.rest.issues.create({ owner, repo, title: TITLE, body }); + + // Open the PR (REST: self-addresses the repo; no gh binary to pin). + if (!NO_PR) { + const prBody = `Bumps the pinned CHAMILO_LMS_REF from ${CUR} to the latest stable release ${LATEST}.\n` + + 'This PR was opened automatically by the weekly dep-drift workflow, which built\n' + + `the image at ${LATEST} before opening it (fail-closed). The push re-runs the build\n` + + 'job as a second gate. Review the one-line diff and merge; no auto-merge.'; + const createdPr = await github.rest.pulls.create({ + owner, repo, title: `build: bump CHAMILO_LMS_REF to ${LATEST}`, + head: BRANCH, base: DEFAULT_BRANCH, body: prBody, + }); + console.log(`Opened PR #${createdPr.data.number}`); + } From 99f5da83194b8955e5f25f984da1d1693181f871 Mon Sep 17 00:00:00 2001 From: jwarnier Date: Fri, 25 Sep 2026 11:04:17 +0200 Subject: [PATCH 25/25] dep-drift: talk to GitHub via the gh CLI, not github-script Replace the embedded github-script JS blocks with the preinstalled gh CLI, so each GitHub operation is a readable one-liner instead of a multi-line JS blob: - gh api releases/latest -> latest stable tag (404-fails if none) - gh api compare -> drift commit count - gh pr list --head ... -> dedup the bump PR by branch - gh pr create --head ... -> open the bump PR (after an explicit push) - gh issue list/create -> dedup + file the drift report / failure issue No installer action or 'gh auth login': gh is preinstalled on ubuntu-latest and GITHUB_TOKEN authenticates it. Build steps, exact action pins, and the fail-closed gate are unchanged. --- .github/workflows/dep-drift.yml | 230 +++++++++++--------------------- 1 file changed, 79 insertions(+), 151 deletions(-) diff --git a/.github/workflows/dep-drift.yml b/.github/workflows/dep-drift.yml index d894584..fadd2ea 100644 --- a/.github/workflows/dep-drift.yml +++ b/.github/workflows/dep-drift.yml @@ -34,9 +34,12 @@ # gate failed). When the pin already equals the latest stable tag it is a # clean no-op — no weekly "all up to date" noise. # -# All GitHub API calls go through actions/github-script (official `actions` -# org action) — no hand-rolled curl/jq. Git (the one-line Dockerfile edit + -# push) runs inside that same action's `exec`, using GITHUB_TOKEN. +# HOW IT TALKS TO GITHUB: the `gh` CLI — preinstalled on the GitHub-hosted +# ubuntu-latest runner and authenticated by GITHUB_TOKEN itself (no installer +# action, no `gh auth login`). Each GitHub operation is a readable one-liner: +# `gh api` for the release/compare lookups, `gh pr` to dedup/open the PR, +# `gh issue` to dedup/file the report. Git is the repo's own (the one-line +# Dockerfile edit + push). No hand-rolled curl/jq, no embedded JS. # # Cost: GitHub-hosted runners are free/unmetered for public repos, so this # is not metered against the 2,000-min/500 MB quota (private only). The real @@ -79,35 +82,27 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4.4.1 - # Detect drift: latest stable tag (chamilo/chamilo-lms) vs the current - # pin, read straight out of the checked-out Dockerfile (no shell grep). - # actions/github-script is the official `actions`-org action for driving - # the GitHub REST API from JS — replaces the old curl | jq step. + # Detect drift: latest stable tag vs the current pin (read out of the + # Dockerfile). `gh` is preinstalled on the runner; GITHUB_TOKEN authenticates + # it (no `gh auth login`). `releases/latest` is the latest STABLE release + # (404s if there is none — let it fail the run, don't guess). - name: Detect drift (latest stable tag vs current pin) id: detect - uses: actions/github-script@v9 - with: - script: | - const fs = require('fs'); - const lmsRepo = process.env.LMS_REPO; - // Current pin: the first "ARG CHAMILO_LMS_REF=" in the Dockerfile. - const df = fs.readFileSync('Dockerfile', 'utf8'); - const m = df.match(/^ARG[ \t]+CHAMILO_LMS_REF=(.+)$/m); - if (!m) throw new Error('CHAMILO_LMS_REF ARG not found in Dockerfile'); - const CUR = m[1].trim(); - // Latest STABLE release tag. releases/latest 404s if there is no - // stable release yet — let it fail the run, don't guess. - const latest = await github.rest.repos.getLatestRelease({ owner: lmsRepo, repo: lmsRepo }); - const LATEST = latest.data.tag_name; - core.setOutput('CUR', CUR); - core.setOutput('LATEST', LATEST); - core.setOutput('DRIFT', CUR === LATEST ? 'false' : 'true'); - console.log(`current pin: ${CUR}`); - console.log(`latest stable: ${LATEST}`); + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + LATEST="$(gh api "repos/${LMS_REPO}/releases/latest" --jq .tag_name)" + CUR="$(sed -n '/^ARG[[:space:]]*CHAMILO_LMS_REF=/{s/^ARG[[:space:]]*CHAMILO_LMS_REF=//p}' Dockerfile)" + DRIFT=false + [ "$CUR" != "$LATEST" ] && DRIFT=true + { echo "LATEST=$LATEST"; echo "CUR=$CUR"; echo "DRIFT=$DRIFT"; } >> "$GITHUB_ENV" + echo "current pin: $CUR" + echo "latest stable: $LATEST" # Up-to-date: nothing to say. No build, no PR, no Issue. - name: Up to date — no-op - if: steps.detect.outputs.DRIFT == 'false' + if: env.DRIFT == 'false' run: echo "CHAMILO_LMS_REF already equals the latest stable tag; nothing to open." # Build at the candidate ref — the gate. Runs only when there is drift. @@ -115,143 +110,76 @@ jobs: # candidate ref as a build-arg. A failure here means NO PR is opened. # # NOTE: the gate conditions use `steps.gate.result` — the standard - # Actions step-status context (success/failure/cancelled/skipped), - # reflecting the build step's own run (`.result` is the current - # spelling; `.outcome` is the legacy alias). When DRIFT=false the gate - # is skipped, so result=='skipped' and neither downstream step runs. + # Actions step-status context (success/failure/cancelled/skipped). When + # DRIFT=false the gate is skipped, so result=='skipped' and neither + # downstream step runs. - name: Build-gate at candidate ref - if: steps.detect.outputs.DRIFT == 'true' id: gate + if: env.DRIFT == 'true' uses: docker/build-push-action@v7.4.0 with: context: . load: true build-args: | - CHAMILO_LMS_REF=${{ steps.detect.outputs.LATEST }} - tags: dep-drift-gate:${{ steps.detect.outputs.LATEST }} + CHAMILO_LMS_REF=${{ env.LATEST }} + tags: dep-drift-gate:${{ env.LATEST }} - # Build failed: NO PR. Record it as the drift Issue instead (dedup by ref). - - name: Record build-gate failure (no PR opened) - if: steps.detect.outputs.DRIFT == 'true' && steps.gate.result == 'failure' + # Build failed: NO PR. File a drift Issue recording the failure (dedup by ref). + - name: File drift Issue (build FAILED — no PR opened) + if: env.DRIFT == 'true' && steps.gate.result == 'failure' env: - LATEST: ${{ steps.detect.outputs.LATEST }} - uses: actions/github-script@v9 - with: - script: | - const lmsRepo = process.env.LMS_REPO; - const LATEST = process.env.LATEST; - const TITLE = `chamilo-lms drift (build FAILED) ${LATEST}`; - // Dedup: an open Issue already records this failure for this ref? - const issues = await github.rest.issues.listForRepo({ - owner: context.repo.owner, repo: context.repo.repo, state: 'open', - }); - const dup = issues.data.find((i) => i.title === TITLE); - if (dup) { - console.log(`A failure Issue for ${LATEST} already exists (#${dup.number}); not duplicating.`); - process.exit(0); - } - const body = `The weekly dep-drift build-gate failed building the image at CHAMILO_LMS_REF=${LATEST},\n` + - 'so NO bump PR was opened (fail-closed). The current pin remains unchanged.\n' + - 'See the dep-drift workflow run for the build log. Once the build passes at this\n' + - 'ref, re-run the workflow to open the PR.'; - const created = await github.rest.issues.create({ - owner: context.repo.owner, repo: context.repo.repo, title: TITLE, body, - }); - console.log(`Opened failure Issue #${created.data.number}`); + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + LATEST: ${{ env.LATEST }} + run: | + set -euo pipefail + TITLE="chamilo-lms drift (build FAILED) ${LATEST}" + if gh issue list --state open --json title --jq '.[].title' | grep -Fxq "$TITLE"; then + echo "A failure Issue for ${LATEST} already exists; not duplicating." + exit 0 + fi + gh issue create --title "$TITLE" --body "Build-gate FAILED building the image at CHAMILO_LMS_REF=${LATEST}; no bump PR was opened (fail-closed). The current pin is unchanged. See the dep-drift run log for the build failure." # Build succeeded: open the one-line bump PR (dedup by branch), then file # the weekly drift report Issue (dedup by from->to pair). - name: Open bump PR + file drift Issue - if: steps.detect.outputs.DRIFT == 'true' && steps.gate.result == 'success' + if: env.DRIFT == 'true' && steps.gate.result == 'success' env: - CUR: ${{ steps.detect.outputs.CUR }} - LATEST: ${{ steps.detect.outputs.LATEST }} - uses: actions/github-script@v9 - with: - script: | - const fs = require('fs'); - const CUR = process.env.CUR; - const LATEST = process.env.LATEST; - const lmsRepo = process.env.LMS_REPO; - const prefix = process.env.PR_BRANCH_PREFIX; - const token = process.env.GITHUB_TOKEN; - const owner = context.repo.owner; - const repo = context.repo.repo; - const BRANCH = `${prefix}${LATEST}`; - const gitIdName = 'github-actions[bot]'; - const gitIdEmail = '41898282+github-actions[bot]@users.noreply.github.com'; - // Default branch of THIS repo (self-addressing: fork master today, - // upstream master if this workflow is copied there later). - const { data: repoInfo } = await github.rest.repos.get({ owner, repo }); - const DEFAULT_BRANCH = repoInfo.default_branch; - - // Dedup: an open PR already targets this ref? - const existingPrs = await github.rest.pulls.list({ owner, repo, state: 'open', head: BRANCH }); - let NO_PR = existingPrs.data.length > 0; - if (NO_PR) { - console.log(`An open PR for ${BRANCH} already exists (#${existingPrs.data[0].number}); skipping the PR.`); - } - - if (!NO_PR) { - // exec.exec RESOLVES with the exit code — it does not throw on a - // non-zero exit — so wrap it: a failed `git push` must fail this - // step, not open a PR pointing at a branch that never got pushed. - const run = async (cmd, args) => { - const code = await exec.exec(cmd, args); - if (code != null && code !== 0) throw new Error(`${cmd} ${args.join(' ')} failed (exit ${code})`); - }; - // One-line Dockerfile edit + push via the action's git. exec.exec - // runs the command with an ARGS ARRAY (no shell), so the tag and - // repo values pass through verbatim — nothing to shell-quote. - await run('git', ['checkout', '-b', BRANCH]); - const df = fs.readFileSync('Dockerfile', 'utf8'); - if (!/^ARG[ \t]+CHAMILO_LMS_REF=.*$/m.test(df)) throw new Error('CHAMILO_LMS_REF ARG not found in Dockerfile'); - fs.writeFileSync('Dockerfile', df.replace(/^ARG[ \t]+CHAMILO_LMS_REF=.*$/m, `ARG CHAMILO_LMS_REF=${LATEST}`)); - await run('git', ['add', 'Dockerfile']); - await run('git', [ - '-c', `user.name=${gitIdName}`, - '-c', `user.email=${gitIdEmail}`, - 'commit', - '-m', `build: bump CHAMILO_LMS_REF to ${LATEST}`, - '-m', `Bumps the pinned ${lmsRepo} ref from ${CUR} to the latest stable release ${LATEST} (one-line, fail-closed: this run built the image at ${LATEST} before opening).`, - ]); - // Push with GITHUB_TOKEN (we hold contents:write, so a fresh-branch - // push to THIS repo is allowed — no PAT needed). - await run('git', ['remote', 'set-url', 'origin', `https://x-access-token:${token}@github.com/${owner}/${repo}.git`]); - await run('git', ['push', '-u', 'origin', BRANCH]); - } + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CUR: ${{ env.CUR }} + LATEST: ${{ env.LATEST }} + PR_BRANCH_PREFIX: ${{ env.PR_BRANCH_PREFIX }} + run: | + set -euo pipefail + BRANCH="${PR_BRANCH_PREFIX}${LATEST}" + TITLE_PR="build: bump CHAMILO_LMS_REF to ${LATEST}" + # Commit identity = the workflow bot (deterministic; no local git config). + BOT_NAME="github-actions[bot]" + BOT_EMAIL="41898282+github-actions[bot]@users.noreply.github.com" - // File the weekly drift report Issue (dedup by from->to). - const TITLE = `chamilo-lms drift ${CUR} -> ${LATEST}`; - const issues = await github.rest.issues.listForRepo({ owner, repo, state: 'open' }); - const dup = issues.data.find((i) => i.title === TITLE); - if (dup) { - console.log(`A drift Issue for this pair already exists (#${dup.number}); not duplicating.`); - process.exit(0); - } - // How many commits the latest tag is ahead of the pin (best-effort). - let AHEAD = '?'; - try { - const cmp = await github.rest.repos.compareCommits({ owner: lmsRepo, repo: lmsRepo, baseCommit: CUR, headCommit: LATEST }); - AHEAD = cmp.data.ahead_by; - } catch (e) { AHEAD = '?'; } - const body = `Weekly dep-drift report.\n` + - `- Current pin: ${CUR}\n` + - `- Latest stable: ${LATEST} (${AHEAD} commits ahead of the pin)\n` + - `- Outcome: build-gate PASSED at ${LATEST}; a bump PR was opened (or already open).\n` + - 'This Issue is the weekly report; the actual change is the linked PR (one-line\n' + - 'Dockerfile diff). Opened automatically by the dep-drift workflow; not auto-merged.'; - const createdIssue = await github.rest.issues.create({ owner, repo, title: TITLE, body }); + # --- The bump PR (dedup by branch: skip if one for this ref is already open). + if [ -z "$(gh pr list --state open --head "$BRANCH" --json headRefName --jq '.[].headRefName' 2>/dev/null || true)" ]; then + git checkout -b "$BRANCH" + # One-line edit: rewrite the ARG line (wherever it is), value -> newest stable. + sed -i '/^ARG[[:space:]]*CHAMILO_LMS_REF=/{s/^ARG[[:space:]]*CHAMILO_LMS_REF=.*/ARG CHAMILO_LMS_REF='"${LATEST}"'/}' Dockerfile + git add Dockerfile + git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \ + commit -m "$TITLE_PR" \ + -m "Bumps the pinned ${LMS_REPO} ref from ${CUR} to the latest stable release ${LATEST} (one-line, fail-closed: this run built the image at ${LATEST} before opening)." + # Push with GITHUB_TOKEN (contents:write -> fresh-branch push to THIS repo is allowed). + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/$(gh repo view --json nameWithOwner -q .nameWithOwner).git" + git push -u origin "$BRANCH" + # `--head` skips any fork/push prompt (the branch is already pushed). + gh pr create --head "$BRANCH" --title "$TITLE_PR" --body "Bumps the pinned CHAMILO_LMS_REF from ${CUR} to the latest stable release ${LATEST}. Opened automatically by the weekly dep-drift workflow, which built the image at ${LATEST} before opening it (fail-closed). The push re-runs build.yml as a second gate. Review the one-line diff; no auto-merge." + else + echo "An open PR for ${BRANCH} already exists; not opening a duplicate." + fi - // Open the PR (REST: self-addresses the repo; no gh binary to pin). - if (!NO_PR) { - const prBody = `Bumps the pinned CHAMILO_LMS_REF from ${CUR} to the latest stable release ${LATEST}.\n` + - 'This PR was opened automatically by the weekly dep-drift workflow, which built\n' + - `the image at ${LATEST} before opening it (fail-closed). The push re-runs the build\n` + - 'job as a second gate. Review the one-line diff and merge; no auto-merge.'; - const createdPr = await github.rest.pulls.create({ - owner, repo, title: `build: bump CHAMILO_LMS_REF to ${LATEST}`, - head: BRANCH, base: DEFAULT_BRANCH, body: prBody, - }); - console.log(`Opened PR #${createdPr.data.number}`); - } + # --- The weekly drift report Issue (dedup by the from->to pair). + TITLE_ISSUE="chamilo-lms drift ${CUR} -> ${LATEST}" + if gh issue list --state open --json title --jq '.[].title' | grep -Fxq "$TITLE_ISSUE"; then + echo "A drift Issue for this pair already exists; not duplicating." + else + # How many commits the latest tag is ahead of the pin (best-effort). + AHEAD="$(gh api "repos/${LMS_REPO}/compare/${CUR}...${LATEST}" --jq .ahead_by 2>/dev/null || echo '?')" + gh issue create --title "$TITLE_ISSUE" --body "Weekly dep-drift report. Current pin ${CUR}, latest stable ${LATEST} (${AHEAD} commits ahead). Build-gate PASSED at ${LATEST}; a bump PR was opened (or is already open). The change is the linked PR (one-line Dockerfile diff). Not auto-merged." + fi