diff --git a/.github/actions/build-docs/action.yml b/.github/actions/build-docs/action.yml new file mode 100644 index 00000000..beb4449f --- /dev/null +++ b/.github/actions/build-docs/action.yml @@ -0,0 +1,22 @@ +name: Build documentation and books +description: Validate both manuscripts and build the site with PDF and EPUB downloads. +runs: + using: composite + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + - name: Install rendering dependencies + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libcairo2 libpango-1.0-0 libpangoft2-1.0-0 fonts-dejavu-core fonts-liberation + python -m venv book/.venv + book/.venv/bin/pip install -r book/build/requirements.txt + - name: Validate and build documentation + shell: bash + run: bash scripts/build-docs.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44b43c18..05f1639f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [ main ] +permissions: + contents: read + jobs: quality: name: Quality (PHP ${{ matrix.php }}) @@ -72,15 +75,49 @@ jobs: if-no-files-found: warn docs: - name: Docs (mkdocs --strict) + name: Docs (site + bilingual books) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: ./.github/actions/build-docs + - uses: actions/upload-artifact@v4 + with: + name: books + path: site/downloads/ + if-no-files-found: error + - uses: actions/upload-pages-artifact@v4 + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + with: + path: site/ + + pages: + name: Publish documentation + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: [quality, browser, docs, guard] + runs-on: ubuntu-latest + concurrency: + group: github-pages + cancel-in-progress: false + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Skip a superseded main build + id: current + uses: actions/github-script@v7 with: - python-version: '3.12' - - run: pip install mkdocs-material - - run: mkdocs build --strict + script: | + const head = await github.rest.repos.getCommit({...context.repo, ref: 'main'}); + return head.data.sha === context.sha; + - uses: actions/configure-pages@v5 + if: steps.current.outputs.result == 'true' + - id: deployment + uses: actions/deploy-pages@v4 + if: steps.current.outputs.result == 'true' guard: name: Pre-push safety guard diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02dc655e..7feccf26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,7 @@ jobs: exit 1 - name: Install the public release and exercise its installer run: php scripts/check-package-install.php --published + - uses: ./.github/actions/build-docs - name: Publish the verified release notes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -78,3 +79,4 @@ jobs: PY gh release view "$RELEASE_TAG" >/dev/null 2>&1 || gh release create "$RELEASE_TAG" --verify-tag --title "LaraFly ${RELEASE_TAG#v}" --notes-file "$RUNNER_TEMP/release-notes.md" + gh release upload "$RELEASE_TAG" site/downloads/* --clobber diff --git a/README.md b/README.md index acb15e6a..80bfbd02 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,10 @@ [*PyFly by Example*](https://github.com/fireflyframework/fireflyframework-pyfly). It builds **Lumen**, the wallet-and-ledger service in [`samples/lumen/`](samples/lumen/), from an empty directory into a secured, event-driven, actuator-observed microservice, chapter by chapter — every listing drawn from that real project -(it boots and its tests pass against this framework version, `26.09.3`). +(its boot and test suite are verified in CI against the same framework source). + +**[Download the book in English or Spanish, as PDF or EPUB](https://fireflyframework.github.io/fireflyframework-php/book/).** +The published editions include the single-package installation and bundled installer. The book is **structurally complete and bilingual (English + Spanish)**: a quick start, **fifteen chapters** across four parts — Foundations (DI, config, HTTP), Modelling & Persisting the Domain (repositories, DDD), diff --git a/book/README.md b/book/README.md index 7b33edea..8245b017 100644 --- a/book/README.md +++ b/book/README.md @@ -11,6 +11,10 @@ and `book/dist/` (the generated PDF/EPUB) are gitignored and never committed. Only the *sources* — `book.yaml`, `build/*.py`, `theme/*.css`, `art/`, `src/` (EN), `src-es/` (ES), `tests/` — are tracked. +**[Published PDF and EPUB editions (English + Español)](https://fireflyframework.github.io/fireflyframework-php/book/)** +are rebuilt with the MkDocs site after every successful `main` CI run. GitHub releases also carry the +books built from their tag. Each download set includes `SHA256SUMS` and a `build-info.json` source commit. + ## One-time setup The first build needs **network access** (to install the Python deps) and a @@ -29,7 +33,7 @@ brew install cairo pango `book/build/run.sh` sets `DYLD_FALLBACK_LIBRARY_PATH` to Homebrew's `lib/` so WeasyPrint finds `libcairo`/`libpango` without any manual `export`. On Linux, -install the equivalent packages (e.g. `apt install libcairo2 libpango-1.0-0`) +install the equivalent packages (e.g. `apt install libcairo2 libpango-1.0-0 libpangoft2-1.0-0`) and `run.sh`'s `DYLD_FALLBACK_LIBRARY_PATH` export is a no-op (Linux uses the system loader path instead). @@ -43,6 +47,13 @@ bash book/build/run.sh --config book.es.yaml # Spanish -> book/dist/larafly-by `book/dist/` is created on demand and is gitignored — nobody commits a generated PDF/EPUB. +To run the same pipeline as CI, including the book tests, PHP listing checks, both editions, +the PDF text-boundary check, the strict MkDocs build and `site/downloads/` packaging: + +```bash +bash scripts/build-docs.sh +``` + ## Verifying PHP code listings A fenced ` ```php ` block is linted with the real PHP CLI (`php -l`, via a temp diff --git a/book/build/requirements.txt b/book/build/requirements.txt index d73fec0c..79c9a331 100644 --- a/book/build/requirements.txt +++ b/book/build/requirements.txt @@ -7,3 +7,6 @@ pygments==2.20.0 pyyaml==6.0.3 pytest==9.1.1 cairosvg==2.9.0 +mkdocs-material==9.7.6 +mkdocs==1.6.1 +pdfplumber==0.11.9 diff --git a/book/build/verify_pdf.py b/book/build/verify_pdf.py new file mode 100644 index 00000000..53547e97 --- /dev/null +++ b/book/build/verify_pdf.py @@ -0,0 +1,31 @@ +"""Reject generated books whose text extends beyond the PDF page boundaries.""" +from __future__ import annotations + +import sys + +import pdfplumber + + +def main(paths: list[str]) -> int: + failures = 0 + for path in paths: + with pdfplumber.open(path) as document: + if not document.pages: + raise ValueError(f'{path}: no PDF pages found') + for number, page in enumerate(document.pages, 1): + # Read the content stream, including text wholly outside the MediaBox. + # Poppler's bounding-box output drops that text before it can be checked. + outside = [char['text'] for char in page.chars if char['text'].strip() and ( + char['x0'] < -1 or char['top'] < -1 + or char['x1'] > page.width + 1 or char['bottom'] > page.height + 1 + )] + if outside: + print(f'FAIL {path}:{number}: off-page text {"".join(outside)!r}') + failures += 1 + page.close() + print(f'{path}: checked {len(document.pages)} pages') + return 1 if failures else 0 + + +if __name__ == '__main__': + raise SystemExit(main(sys.argv[1:])) diff --git a/book/src-es/00-front/00-preface.md b/book/src-es/00-front/00-preface.md index 302598aa..9467405c 100644 --- a/book/src-es/00-front/00-preface.md +++ b/book/src-es/00-front/00-preface.md @@ -16,7 +16,7 @@ Los desarrolladores que llegan desde Spring Boot, Micronaut o Quarkus se sentir Cada capítulo hace avanzar **Lumen**, un servicio de monedero digital y libro mayor: un `Wallet` puede abrirse, recibir depósitos, sufrir retiradas y transferirse a otros monederos, protegiendo un único invariante por encima de todos los demás — **el saldo nunca es negativo** — y registrando cada cambio de estado como un evento de dominio que un escuchador proyecta en un libro mayor de solo anexado. Es un sistema pequeño, pero tiene exactamente la forma de uno real: una capa de dominio sin dependencia alguna del framework, un puerto hexagonal y su adaptador Eloquent, manejadores de comando y consulta de CQRS, eventos de dominio conectados a un bus de eventos, seguridad a nivel de método en una operación sensible, y un controlador REST delgado que no contiene lógica de negocio propia. -El recorrido empieza con suavidad. El **Inicio rápido** te lleva desde un `composer create-project` vacío hasta un endpoint en ejecución y consultable con curl, previendo en miniatura los estereotipos, el contenedor y la ruta de arranque compilada antes de que ningún capítulo te pida razonar sobre ellos. El **Capítulo 1** da un paso atrás y argumenta el enfoque completo — qué problema resuelve LaraFly y sobre qué pilares se sostiene. El **Capítulo 2** abre la sala de máquinas: el contenedor de inyección de dependencias, los atributos de estereotipo y el escaneo de componentes que compila tus clases anotadas en un manifiesto de arranque en caché, sin reflexión. Las partes posteriores de este libro — que llegarán en los capítulos siguientes — construyen hacia afuera desde esa base hacia la configuración, HTTP, la persistencia, el modelado de dominio, CQRS, la arquitectura orientada a eventos, la seguridad y la observabilidad, siempre a través de la misma base de código de `Lumen`, siempre con código que puedes ejecutar. +El recorrido empieza con suavidad. El **Inicio rápido** te lleva desde el instalador incluido `firefly new` hasta un endpoint en ejecución y consultable con curl, previendo en miniatura los estereotipos, el contenedor y la ruta de arranque compilada antes de que ningún capítulo te pida razonar sobre ellos. El **Capítulo 1** da un paso atrás y argumenta el enfoque completo — qué problema resuelve LaraFly y sobre qué pilares se sostiene. El **Capítulo 2** abre la sala de máquinas: el contenedor de inyección de dependencias, los atributos de estereotipo y el escaneo de componentes que compila tus clases anotadas en un manifiesto de arranque en caché, sin reflexión. Las partes posteriores de este libro — que llegarán en los capítulos siguientes — construyen hacia afuera desde esa base hacia la configuración, HTTP, la persistencia, el modelado de dominio, CQRS, la arquitectura orientada a eventos, la seguridad y la observabilidad, siempre a través de la misma base de código de `Lumen`, siempre con código que puedes ejecutar. Cuando hayas terminado de trabajar todo el libro, tendrás un modelo mental de cada capa de un servicio LaraFly de producción, y una aplicación real y probada que lo demuestre. diff --git a/book/src-es/11-observability-actuator.md b/book/src-es/11-observability-actuator.md index e3415f14..a4fea674 100644 --- a/book/src-es/11-observability-actuator.md +++ b/book/src-es/11-observability-actuator.md @@ -302,7 +302,7 @@ Configurado de esta manera, `GET /actuator/health/liveness` agrega solo `ping` ( `show-details` (`never`/`when-authorized`/`always`, por defecto `never`) rige si la respuesta incluye el mapa `details` por componente en absoluto — con `never`, un llamante no autenticado ve solo el `status` agregado, nunca *qué* indicador falló ni por qué. Cualquier otro valor, *incluida una errata*, se lee como `never`: la dirección que falla cerrando, porque el coste de retener un detalle es una consulta de soporte y el de publicarlo es una divulgación. -Y `when-authorized` ya significa lo que su nombre dice. Durante dos versiones se degradaba a `never`, porque `firefly/actuator` no tiene ninguna arista de código hacia `firefly/security` y por tanto no podía decir *quién preguntaba* — seguro, y también una mentira documentada sobre lo que el valor hacía. `26.09.4` lo cerró con una forma que este libro ya te ha enseñado — el puerto `CqrsMetrics` del Capítulo 7 y su implementación no-op por defecto son el mismo movimiento: el paquete dueño de la **pregunta** declara un puerto y trae la respuesta conservadora, y el paquete dueño de la **respuesta** lo rellena. El puerto es `Firefly\Actuator\Health\HealthDetailsAuthorizer`, un `mayReadDetails(): bool` deliberadamente sin argumentos; la implementación propia del actuator es `DenyHealthDetailsAuthorizer`, que se lo niega a todo el mundo y es el comportamiento antiguo con otro nombre. Instala `firefly/security` con `firefly.security.enabled` encendido y `PrincipalHealthDetailsAuthorizer` toma el relevo: lee el principal guardado en la sesión del mismo `SecurityContextHolder` que leen las reglas del Capítulo 10, y lo compara con `firefly.management.endpoint.health.roles` a través de la misma `RoleHierarchy`, de modo que `ROLE_ADMIN > ROLE_ACTUATOR` concede lo que dice conceder. Un `roles` vacío es el "cualquier principal autenticado" de Spring; uno no vacío (una lista, o la cadena CSV de Spring — un `ADMIN` a secas se lee como `ROLE_ADMIN`) lo estrecha más, y un valor que no puede leerse como una lista de roles se lo niega a todos en lugar de admitirlos. La arista de dependencia nueva es `Security → Actuator` y solo en esa dirección: `firefly/actuator` sigue sin nombrar ningún tipo de principal y sigue funcionando en una aplicación sin seguridad alguna. Ambos beans son `#[ConditionalOnMissingBean]`, así que tu propio `HealthDetailsAuthorizer` desplaza a cualquiera de los dos. +Y `when-authorized` ya significa lo que su nombre dice. Durante dos versiones se degradaba a `never`, porque `firefly/actuator` no tiene ninguna arista de código hacia `firefly/security` y por tanto no podía decir *quién preguntaba* — seguro, y también una mentira documentada sobre lo que el valor hacía. `26.09.4` lo cerró con una forma que este libro ya te ha enseñado — el puerto `CqrsMetrics` del Capítulo 7 y su implementación no-op por defecto son el mismo movimiento: el paquete dueño de la **pregunta** declara un puerto y trae la respuesta conservadora, y el paquete dueño de la **respuesta** lo rellena. El puerto es `Firefly\Actuator\Health\HealthDetailsAuthorizer`, un `mayReadDetails(): bool` deliberadamente sin argumentos; la implementación propia del actuator es `DenyHealthDetailsAuthorizer`, que se lo niega a todo el mundo y es el comportamiento antiguo con otro nombre. Activa el componente incluido `firefly/security` con `firefly.security.enabled` y `PrincipalHealthDetailsAuthorizer` toma el relevo: lee el principal guardado en la sesión del mismo `SecurityContextHolder` que leen las reglas del Capítulo 10, y lo compara con `firefly.management.endpoint.health.roles` a través de la misma `RoleHierarchy`, de modo que `ROLE_ADMIN > ROLE_ACTUATOR` concede lo que dice conceder. Un `roles` vacío es el "cualquier principal autenticado" de Spring; uno no vacío (una lista, o la cadena CSV de Spring — un `ADMIN` a secas se lee como `ROLE_ADMIN`) lo estrecha más, y un valor que no puede leerse como una lista de roles se lo niega a todos en lugar de admitirlos. La arista de dependencia nueva es `Security → Actuator` y solo en esa dirección: `firefly/actuator` sigue sin nombrar ningún tipo de principal y sigue funcionando en una aplicación sin seguridad alguna. Ambos beans son `#[ConditionalOnMissingBean]`, así que tu propio `HealthDetailsAuthorizer` desplaza a cualquiera de los dos. !!! warning "Cambio rompedor en `26.09.4` — mira `CHANGELOG.md` bajo `[26.09.4]`" Dos cosas se movieron, y ambas están registradas allí bajo **BREAKING**. Primero, **una aplicación que ya corría `show-details: when-authorized` con la seguridad encendida empieza a divulgar lo que antes retenía**: con el `roles` por defecto (`[]`, "cualquier principal autenticado"), todo llamante autenticado lee ahora los detalles por componente — que nombran drivers de base de datos, rutas de disco, hosts de brókeres y mensajes de error de los indicadores. Decide en vez de heredar: pon `show-details: never`, o enumera en `firefly.management.endpoint.health.roles` los roles que pueden leerlos, o enlaza tu propio `HealthDetailsAuthorizer`. Segundo, **el constructor de `HealthEndpoint` ganó un cuarto parámetro obligatorio**, `HealthDetailsAuthorizer $authorizer`, de modo que el código que construye el endpoint directamente en lugar de resolver el bean tiene que pasar uno — `new DenyHealthDetailsAuthorizer` reproduce exactamente el comportamiento antiguo. Una aplicación sin `firefly/security`, o con su flag maestro apagado, no se ve afectada: el rechazo por defecto se mantiene y el cuerpo de la respuesta es byte por byte el que era. @@ -1251,8 +1251,8 @@ Es un *interruptor de funcionalidad*, no un endpoint de configuración remota: l 1. **Añade un `HealthIndicator` personalizado.** Escribe un `#[Component]` que implemente `HealthIndicator` que compruebe algo específico de tu propia app (un feature flag, la profundidad de una cola, una conexión de caché) y confirma que aparece en el mapa `components` de `GET /actuator/health` una vez que `show-details` esté configurado a `always`. 2. **Configura una división liveness/readiness real.** Añade `firefly.management.endpoint.health.group.liveness.include = 'ping'` y `...readiness.include = 'ping,db'` (con el indicador de BD habilitado) a la configuración de un proyecto de pruebas, y confirma que `GET /actuator/health/liveness` y `GET /actuator/health/readiness` divergen en el momento en que dejas la base de datos inalcanzable. -3. **Observa a la costura de métricas de CQRS ganar la carrera.** Instala `firefly/observability` en un proyecto de pruebas que ya use `firefly/cqrs`, envía un puñado de comandos, e inspecciona `GET /actuator/prometheus` en busca de muestras de `cqrs_commands_seconds` — luego comenta temporalmente el atributo `#[Order(500)]` de `ObservabilityAutoConfiguration` (revirtiendo al valor por defecto de la clase) y confirma si la métrica todavía aparece, para ver el truco de ordenamiento importar de verdad en lugar de solo leer sobre él. -4. **Demuéstrate a ti mismo el sorteo de la exposición.** Instala `firefly/admin` en el sample, deja `firefly.management.endpoints.web.exposure.include` en su valor por defecto, y confirma que `GET /actuator/beans` devuelve un `404` mientras `/firefly/beans` renderiza la lista completa de beans en el mismo proceso. Luego pon `firefly.management.endpoint.beans.enabled` a `false` y confirma que la entrada Beans desaparece del menú del panel — el interruptor de apagado se honra allí donde la exposición no, y la diferencia entre ambas claves es todo el diseño. +3. **Observa a la costura de métricas de CQRS ganar la carrera.** Usa el componente incluido `firefly/observability` en un proyecto LaraFly de pruebas que ya use `firefly/cqrs`, envía un puñado de comandos, e inspecciona `GET /actuator/prometheus` en busca de muestras de `cqrs_commands_seconds` — luego comenta temporalmente el atributo `#[Order(500)]` de `ObservabilityAutoConfiguration` (revirtiendo al valor por defecto de la clase) y confirma si la métrica todavía aparece, para ver el truco de ordenamiento importar de verdad en lugar de solo leer sobre él. +4. **Demuéstrate a ti mismo el sorteo de la exposición.** Activa el componente incluido `firefly/admin` con `firefly.admin.enabled` en el sample, deja `firefly.management.endpoints.web.exposure.include` en su valor por defecto, y confirma que `GET /actuator/beans` devuelve un `404` mientras `/firefly/beans` renderiza la lista completa de beans en el mismo proceso. Luego pon `firefly.management.endpoint.beans.enabled` a `false` y confirma que la entrada Beans desaparece del menú del panel — el interruptor de apagado se honra allí donde la exposición no, y la diferencia entre ambas claves es todo el diseño. 5. **Dibuja tu propio cableado y luego rómpelo.** Abre `/firefly/graph` en el sample y encuentra la flecha de `WalletService` a `EloquentWalletRepository` — fíjate en que la columna *Wired by* dice `WalletRepository`, el puerto, y no `class`. Después introduce un ciclo deliberado (haz que un `#[Service]` tome un parámetro de constructor tipado como otro `#[Service]` que ya depende de él), recarga la página, y confirma que la estadística **Cycles** se pone en rojo y nombra ambas clases. Ahora arranca la app de cero sin abrir el panel, y compara lo que PHP te cuenta sobre ese mismo ciclo. 6. **Lee el valor por defecto de acceso como una decisión de seguridad.** Pon `app.debug` a `false` en un proyecto de pruebas con `firefly/admin` instalado y confirma que `/firefly` está genuinamente sin enrutar y no simplemente sin enlazar (`php artisan route:list` no debería listarla). Luego pon `firefly.admin.enabled` a `true` sin añadir ninguna regla de `HttpSecurity`, y mira qué divulga ahora un `GET /firefly/env` sin autenticar — esa es exactamente la brecha que este capítulo te dijo que cerraras con tu propio middleware de autenticación. 7. **Convierte un summary en algo de lo que puedas sacar un p99.** Haz un scrape de `GET /actuator/prometheus` en un proyecto con `firefly/observability` instalado y busca la familia `http_server_requests_seconds`: fíjate en la línea `# TYPE … summary` y en que todo lo que tienes es `_count` y `_sum`. Después pon `firefly.observability.metrics.distribution.buckets` con la lista por defecto de los clientes que este capítulo imprime, vuelve a hacer el scrape, y confirma que la línea `# TYPE` ahora dice `histogram` y que apareció una serie `_bucket{le=…}` por cada cota. Ahora silencia solo ese medidor otra vez con una lista `distribution.per-meter.http_server_requests_seconds` **vacía**, y confirma que todos los demás temporizadores conservan sus buckets. Por último mete un `0` — o un `-1`, o una cadena — en la lista global y confirma que el arranque lo rechaza por su nombre en vez de descartar la cota calladamente. diff --git a/book/src/00-front/00-preface.md b/book/src/00-front/00-preface.md index fa737f11..3a893ebf 100644 --- a/book/src/00-front/00-preface.md +++ b/book/src/00-front/00-preface.md @@ -16,7 +16,7 @@ Developers coming from Spring Boot, Micronaut, or Quarkus will feel especially a Every chapter advances **Lumen**, a digital-wallet-and-ledger service: a `Wallet` can be opened, deposited to, withdrawn from, and transferred between other wallets, protecting one invariant above all others — **the balance never goes negative** — and recording every state change as a domain event that a listener projects into an append-only ledger. It is a small system, but it is shaped exactly like a real one: a domain layer with no framework dependency, a hexagonal port and its Eloquent adapter, CQRS command and query handlers, domain events bridged to an event bus, method-level security on a sensitive operation, and a thin REST controller that holds no business logic of its own. -The journey starts gently. The **Quick Start** takes you from an empty `composer create-project` to a running, curl-able endpoint, previewing the stereotypes, the container, and the compiled boot path in miniature before any chapter asks you to reason about them. **Chapter 1** steps back and makes the case for the whole approach — what problem LaraFly solves and the pillars it stands on. **Chapter 2** opens the engine room: the dependency-injection container, the stereotype attributes, and the component scan that compiles your annotated classes into a cached, zero-reflection boot manifest. Later parts of this book — arriving in the chapters that follow — build outward from that foundation into configuration, HTTP, persistence, domain modelling, CQRS, event-driven architecture, security, and observability, always through the same `Lumen` codebase, always with code you can run. +The journey starts gently. The **Quick Start** takes you from the bundled `firefly new` installer to a running, curl-able endpoint, previewing the stereotypes, the container, and the compiled boot path in miniature before any chapter asks you to reason about them. **Chapter 1** steps back and makes the case for the whole approach — what problem LaraFly solves and the pillars it stands on. **Chapter 2** opens the engine room: the dependency-injection container, the stereotype attributes, and the component scan that compiles your annotated classes into a cached, zero-reflection boot manifest. Later parts of this book — arriving in the chapters that follow — build outward from that foundation into configuration, HTTP, persistence, domain modelling, CQRS, event-driven architecture, security, and observability, always through the same `Lumen` codebase, always with code you can run. By the time you have worked through the full book, you will have a mental model for every layer of a production LaraFly service, and a real, tested application to show for it. diff --git a/book/src/11-observability-actuator.md b/book/src/11-observability-actuator.md index 0acbb9bd..c437f4ee 100644 --- a/book/src/11-observability-actuator.md +++ b/book/src/11-observability-actuator.md @@ -302,7 +302,7 @@ Configured this way, `GET /actuator/health/liveness` aggregates only `ping` (so `show-details` (`never`/`when-authorized`/`always`, default `never`) governs whether the response includes the per-component `details` map at all — with `never`, an unauthenticated caller sees only the aggregate `status`, never *which* indicator failed or why. Anything else, *including a typo*, reads as `never`: the fail-closed direction, because the cost of withholding a detail is a support question and the cost of publishing one is a disclosure. -And `when-authorized` now means what its name says. For two releases it degraded to `never`, because `firefly/actuator` has no code edge to `firefly/security` and therefore could not say *who was asking* — safe, and also a documented lie about what the value did. `26.09.4` closed it with a shape this book has already shown you — Chapter 7's `CqrsMetrics` port and its no-op default are the same move: the package that owns the **question** declares a port and ships the conservative answer, and the package that owns the **answer** fills it. The port is `Firefly\Actuator\Health\HealthDetailsAuthorizer`, a deliberately argument-less `mayReadDetails(): bool`; actuator's own implementation is `DenyHealthDetailsAuthorizer`, which refuses everybody and is the old behavior by another name. Install `firefly/security` with `firefly.security.enabled` on and `PrincipalHealthDetailsAuthorizer` takes over: it reads the session-held principal from the same `SecurityContextHolder` Chapter 10's rules read, and matches it against `firefly.management.endpoint.health.roles` through the same `RoleHierarchy`, so `ROLE_ADMIN > ROLE_ACTUATOR` grants what it says. An empty `roles` is Spring's "any authenticated principal"; a non-empty one (a list, or Spring's CSV string — a bare `ADMIN` is read as `ROLE_ADMIN`) narrows it further, and a value that cannot be read as a role list at all refuses everybody rather than admitting them. The new dependency edge is `Security → Actuator` and only that direction: `firefly/actuator` still names no principal type and still works in an application with no security at all. Both beans are `#[ConditionalOnMissingBean]`, so your own `HealthDetailsAuthorizer` displaces either one. +And `when-authorized` now means what its name says. For two releases it degraded to `never`, because `firefly/actuator` has no code edge to `firefly/security` and therefore could not say *who was asking* — safe, and also a documented lie about what the value did. `26.09.4` closed it with a shape this book has already shown you — Chapter 7's `CqrsMetrics` port and its no-op default are the same move: the package that owns the **question** declares a port and ships the conservative answer, and the package that owns the **answer** fills it. The port is `Firefly\Actuator\Health\HealthDetailsAuthorizer`, a deliberately argument-less `mayReadDetails(): bool`; actuator's own implementation is `DenyHealthDetailsAuthorizer`, which refuses everybody and is the old behavior by another name. Enable the included `firefly/security` component with `firefly.security.enabled` and `PrincipalHealthDetailsAuthorizer` takes over: it reads the session-held principal from the same `SecurityContextHolder` Chapter 10's rules read, and matches it against `firefly.management.endpoint.health.roles` through the same `RoleHierarchy`, so `ROLE_ADMIN > ROLE_ACTUATOR` grants what it says. An empty `roles` is Spring's "any authenticated principal"; a non-empty one (a list, or Spring's CSV string — a bare `ADMIN` is read as `ROLE_ADMIN`) narrows it further, and a value that cannot be read as a role list at all refuses everybody rather than admitting them. The new dependency edge is `Security → Actuator` and only that direction: `firefly/actuator` still names no principal type and still works in an application with no security at all. Both beans are `#[ConditionalOnMissingBean]`, so your own `HealthDetailsAuthorizer` displaces either one. !!! warning "Breaking change in `26.09.4` — see `CHANGELOG.md` under `[26.09.4]`" Two things moved, and both are recorded there under **BREAKING**. First, **an application already running `show-details: when-authorized` with security on starts disclosing what it used to withhold**: at the default `roles` (`[]`, "any authenticated principal"), every authenticated caller now reads the component details — which name database drivers, disk paths, broker hosts and indicator error messages. Decide rather than inherit: set `show-details: never`, or list the roles that may read them in `firefly.management.endpoint.health.roles`, or bind your own `HealthDetailsAuthorizer`. Second, **`HealthEndpoint`'s constructor gained a required fourth parameter**, `HealthDetailsAuthorizer $authorizer`, so code that constructs the endpoint directly instead of resolving the bean must now pass one — `new DenyHealthDetailsAuthorizer` reproduces the old behavior exactly. An application with `firefly/security` absent, or its master flag off, is unaffected: the deny default stands and the response body is byte for byte what it was. @@ -1251,8 +1251,8 @@ It is a *feature switch*, not a remote configuration endpoint: the list is fixed 1. **Add a custom `HealthIndicator`.** Write a `#[Component]` implementing `HealthIndicator` that checks something specific to your own app (a feature flag, a queue depth, a cache connection) and confirm it appears in `GET /actuator/health`'s `components` map once `show-details` is set to `always`. 2. **Configure a real liveness/readiness split.** Add `firefly.management.endpoint.health.group.liveness.include = 'ping'` and `...readiness.include = 'ping,db'` (with the DB indicator enabled) to a scratch project's config, and confirm `GET /actuator/health/liveness` and `GET /actuator/health/readiness` diverge the moment you make the database unreachable. -3. **Watch the CQRS metrics seam win the race.** Install `firefly/observability` into a scratch project already using `firefly/cqrs`, send a handful of commands, and inspect `GET /actuator/prometheus` for `cqrs_commands_seconds` samples — then temporarily comment out `ObservabilityAutoConfiguration`'s `#[Order(500)]` attribute (reverting to the class default) and confirm whether the metric still appears, to see the ordering trick actually matter rather than just reading about it. -4. **Prove the dashboard's exposure bypass to yourself.** Install `firefly/admin` in the sample, leave `firefly.management.endpoints.web.exposure.include` at its default, and confirm that `GET /actuator/beans` returns a `404` while `/firefly/beans` renders the full bean list in the same process. Then set `firefly.management.endpoint.beans.enabled` to `false` and confirm the Beans entry vanishes from the dashboard's menu — the kill switch is honoured where exposure is not, and the difference between the two keys is the whole design. +3. **Watch the CQRS metrics seam win the race.** Use the included `firefly/observability` component in a scratch LaraFly project already using `firefly/cqrs`, send a handful of commands, and inspect `GET /actuator/prometheus` for `cqrs_commands_seconds` samples — then temporarily comment out `ObservabilityAutoConfiguration`'s `#[Order(500)]` attribute (reverting to the class default) and confirm whether the metric still appears, to see the ordering trick actually matter rather than just reading about it. +4. **Prove the dashboard's exposure bypass to yourself.** Enable the included `firefly/admin` component with `firefly.admin.enabled` in the sample, leave `firefly.management.endpoints.web.exposure.include` at its default, and confirm that `GET /actuator/beans` returns a `404` while `/firefly/beans` renders the full bean list in the same process. Then set `firefly.management.endpoint.beans.enabled` to `false` and confirm the Beans entry vanishes from the dashboard's menu — the kill switch is honoured where exposure is not, and the difference between the two keys is the whole design. 5. **Draw your own wiring, then break it.** Open `/firefly/graph` in the sample and find the arrow from `WalletService` to `EloquentWalletRepository` — note that the *Wired by* column says `WalletRepository`, the port, not `class`. Then introduce a deliberate cycle (have a `#[Service]` take a constructor parameter typed as another `#[Service]` that already depends on it), reload the page, and confirm the **Cycles** stat turns red and names both classes. Now boot the app fresh without opening the dashboard, and compare what PHP tells you about the same cycle. 6. **Read the access default as a security decision.** Set `app.debug` to `false` in a scratch project with `firefly/admin` installed and confirm `/firefly` is genuinely unrouted rather than merely unlinked (`php artisan route:list` should not list it). Then set `firefly.admin.enabled` to `true` without adding any `HttpSecurity` rule, and look at what an unauthenticated `GET /firefly/env` now discloses — that is precisely the gap this chapter told you to close with your own auth middleware. 7. **Turn a summary into something you can take a p99 of.** Scrape `GET /actuator/prometheus` in a project with `firefly/observability` installed and find the `http_server_requests_seconds` family: note the `# TYPE … summary` line and that all you have is `_count` and `_sum`. Then set `firefly.observability.metrics.distribution.buckets` to the client default list this chapter prints, re-scrape, and confirm the `# TYPE` line now reads `histogram` and a `_bucket{le=…}` series appeared per bound. Now silence just that one meter again with an **empty** `distribution.per-meter.http_server_requests_seconds` list, and confirm every other timer keeps its buckets. Finally put a `0` — or a `-1`, or a string — into the global list and confirm the boot refuses it by name rather than quietly dropping the bound. diff --git a/book/tests/test_verify_pdf.py b/book/tests/test_verify_pdf.py new file mode 100644 index 00000000..c31ef41f --- /dev/null +++ b/book/tests/test_verify_pdf.py @@ -0,0 +1,34 @@ +import pytest + +from build.verify_pdf import main + + +def write_text_pdf(path, x): + """A one-page PDF with a known text position, including outside the MediaBox.""" + stream = f'BT /F1 12 Tf {x} 100 Td (outside) Tj ET'.encode() + objects = [ + b'<< /Type /Catalog /Pages 2 0 R >>', + b'<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + b'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] ' + b'/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>', + b'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + f'<< /Length {len(stream)} >>\nstream\n'.encode() + stream + b'\nendstream', + ] + pdf = bytearray(b'%PDF-1.4\n') + offsets = [] + for number, obj in enumerate(objects, 1): + offsets.append(len(pdf)) + pdf.extend(f'{number} 0 obj\n'.encode() + obj + b'\nendobj\n') + xref = len(pdf) + pdf.extend(b'xref\n0 6\n0000000000 65535 f \n') + for offset in offsets: + pdf.extend(f'{offset:010d} 00000 n \n'.encode()) + pdf.extend(f'trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n'.encode()) + path.write_bytes(pdf) + + +@pytest.mark.parametrize(('x', 'result'), [(20, 0), (190, 1), (210, 1)]) +def test_detects_partial_and_wholly_off_page_text(tmp_path, x, result): + path = tmp_path / 'positioned-text.pdf' + write_text_pdf(path, x) + assert main([str(path)]) == result diff --git a/book/theme/print.css b/book/theme/print.css index 5f2c1a5b..cac7c537 100644 --- a/book/theme/print.css +++ b/book/theme/print.css @@ -34,6 +34,9 @@ p{ orphans:3; widows:3; } (the latter is what all real chapters use — previously unconstrained -> overflowed). */ img,svg,table,pre.code,div.code{ max-width:100%; } pre.code, div.code > pre{ white-space:pre-wrap; overflow-wrap:anywhere; } +/* Long PHP type names must not set a table's minimum width beyond the page. */ +table{ table-layout:fixed; } +th,td,th code,td code{ overflow-wrap:anywhere; } /* ============ Contents page (print) ============ */ /* start on its own page; suppress the running head (no chtitle here) */ diff --git a/docs/book.md b/docs/book.md new file mode 100644 index 00000000..6c49c094 --- /dev/null +++ b/docs/book.md @@ -0,0 +1,41 @@ +# LaraFly by Example + +Learn LaraFly by building **Lumen**, the wallet-and-ledger service included in the framework. +Both editions cover the same application: a quick start, fifteen chapters, a Laravel cheat-sheet, +and a glossary. Every PHP listing is checked against the repository or linted by PHP. + +## Download the book + +| Edition | PDF | EPUB | +|---|---|---| +| English | [Download PDF](https://fireflyframework.github.io/fireflyframework-php/downloads/larafly-by-example.pdf) | [Download EPUB](https://fireflyframework.github.io/fireflyframework-php/downloads/larafly-by-example.epub) | +| Español | [Descargar PDF](https://fireflyframework.github.io/fireflyframework-php/downloads/larafly-by-example-es.pdf) | [Descargar EPUB](https://fireflyframework.github.io/fireflyframework-php/downloads/larafly-by-example-es.epub) | + +These downloads track the latest `main` commit that passes all CI checks. The +[build record](https://fireflyframework.github.io/fireflyframework-php/downloads/build-info.json) +identifies the source commit, and +[SHA256SUMS](https://fireflyframework.github.io/fireflyframework-php/downloads/SHA256SUMS) +lets you verify the downloaded files. Versioned copies are attached to +[GitHub releases](https://github.com/fireflyframework/fireflyframework-php/releases). + +## Start with the current installation + +The book uses the single Composer package, `fireflyframework/larafly`, and its bundled installer: + +```bash +composer global require fireflyframework/larafly +firefly new my-app +``` + +All 29 components are included in that package. An existing application starts with +`composer require fireflyframework/larafly`; compatible legacy `firefly/*` requirements are then +satisfied by the framework's `replace` declarations. See [Installation](installation.md) for details. + +## Read and contribute + +Browse the [English manuscript](https://github.com/fireflyframework/fireflyframework-php/tree/main/book/src) +or [Spanish manuscript](https://github.com/fireflyframework/fireflyframework-php/tree/main/book/src-es), +run the [Lumen sample](https://github.com/fireflyframework/fireflyframework-php/tree/main/samples/lumen), +or follow the [book build instructions](https://github.com/fireflyframework/fireflyframework-php/blob/main/book/README.md). + +The shorter [English tutorial](tutorial.md) and [tutorial en español](tutorial.es.md) are also available online. diff --git a/docs/index.md b/docs/index.md index cfc95d05..b8327686 100644 --- a/docs/index.md +++ b/docs/index.md @@ -127,7 +127,7 @@ Want to see it all working together? The runnable digital-wallet & ledger vertical slice exercising `#[Transactional]`, CQRS, domain events over EDA, method security, and a REST layer with RFC-7807 problem-details. The guided, book-style *LaraFly by Example* book — 15 chapters plus appendices, bilingual (English + Spanish), building this exact sample — is available -in [`book/`](https://github.com/fireflyframework/fireflyframework-php/tree/main/book). +as [PDF and EPUB downloads in English and Spanish](book.md). ## Quick Links diff --git a/docs/publishing.md b/docs/publishing.md index 74b35a68..9a9d7699 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -69,7 +69,8 @@ CI runs these package checks on PHP 8.3, 8.4 and 8.5 for PRs to `main` and pushe 5. Push the new release tag. **Release (single package)** validates the tagged distribution on PHP 8.3, 8.4 and 8.5, waits for Packagist to index that exact commit, then installs it in a fresh stable consumer without custom repositories and exercises the bundled installer. Only after those checks - pass does its final job create the GitHub release from the changelog. That job uses the repository's + pass does its final job build both books and create the GitHub release from the changelog with the + English and Spanish PDF/EPUB files, checksums and source commit record. That job uses the repository's built-in `GITHUB_TOKEN` with `contents: write`; validation jobs remain read-only. 6. If indexing times out, repair the Packagist webhook or trigger an update on the package page, then rerun the failed job. Do not move the tag or create a GitHub release to bypass the public install gate. @@ -81,3 +82,20 @@ RELEASE_TAG=v26.09.9 RELEASE_SHA="$(git rev-parse 'v26.09.9^{commit}')" php scri ``` An unmerged branch or a local consumer check is not a published release. + +## Publish the documentation and books + +CI builds the MkDocs site and both book editions using the shared `.github/actions/build-docs` action. +It runs the book pipeline tests, validates the PHP listings in both languages, renders the PDF/EPUB files, +rejects PDF text outside the page boundaries, and builds MkDocs with `--strict`. +The four books, `SHA256SUMS` and `build-info.json` are included under +the site's `downloads/` directory and uploaded as the `books` artifact for review on PRs. + +For pushes to `main`, the **Publish documentation** job deploys that exact site artifact only after all +quality, browser, documentation and safety checks pass. PRs build and validate; they cannot deploy. +The live [book page](book.md) links to the latest successful main build. Release downloads are built +separately from the tagged source. A release rerun refreshes its generated assets from that same tag. + +Repository setup requires **Settings → Pages → Build and deployment → Source: GitHub Actions**, and the +`github-pages` environment must permit deployments from `main`. The deployment job alone receives +`pages: write` and `id-token: write`. The former `gh-pages` branch is no longer the publication source. diff --git a/mkdocs.yml b/mkdocs.yml index fcf923da..8a3b88d0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,6 +1,6 @@ site_name: LaraFly — Firefly Framework for PHP site_description: Spring Boot's cohesion, native to Laravel. -# The published site (GitHub Pages, from the gh-pages branch `mkdocs gh-deploy` writes). site_url is what +# The published site (GitHub Pages, deployed by CI after all checks pass). site_url is what # gives every page a canonical link and the search index its absolute paths; without it Material emits # relative canonicals and a page opened from a search engine can resolve its own links to the wrong host. site_url: https://fireflyframework.github.io/fireflyframework-php/ @@ -113,6 +113,7 @@ nav: - Getting Started: getting-started.md - Tutorial: tutorial.md - Tutorial (Español): tutorial.es.md + - Book (English & Español): book.md - Architecture: architecture.md - Laravel Comparison: laravel-comparison.md - Modules: diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh new file mode 100644 index 00000000..d0f6e2c6 --- /dev/null +++ b/scripts/build-docs.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." +book/.venv/bin/python -m pytest -q book/tests +book/.venv/bin/python book/build/verify_code.py book/src --require-provenance +book/.venv/bin/python book/build/verify_code.py book/src-es --require-provenance +bash book/build/run.sh +bash book/build/run.sh --config book.es.yaml +book/.venv/bin/python book/build/verify_pdf.py book/dist/larafly-by-example.pdf book/dist/larafly-by-example-es.pdf +book/.venv/bin/mkdocs build --strict + +# Keep generated downloads outside docs/ so a standalone MkDocs build needs no books. +book/.venv/bin/python - <<'PY' +import hashlib +import json +from pathlib import Path +import shutil +import subprocess + +downloads = Path('site/downloads') +downloads.mkdir(parents=True, exist_ok=True) +checksums = [] +for edition in ('larafly-by-example', 'larafly-by-example-es'): + for extension in ('pdf', 'epub'): + name = f'{edition}.{extension}' + target = downloads / name + shutil.copyfile(Path('book/dist') / name, target) + checksums.append(f'{hashlib.sha256(target.read_bytes()).hexdigest()} {name}\n') +revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() +(downloads / 'build-info.json').write_text(json.dumps({ + 'source_commit': revision, + 'source_url': f'https://github.com/fireflyframework/fireflyframework-php/tree/{revision}', +}, indent=2) + '\n') +(downloads / 'SHA256SUMS').write_text(''.join(checksums)) +PY