|
| 1 | +# `tools/release/` |
| 2 | + |
| 3 | +PHP foundation for OpenEMR's release tooling. Used by the workflows in |
| 4 | +`.github/workflows/release-*.yml` and `ship-release.yml`, plus called locally |
| 5 | +during release prep. |
| 6 | + |
| 7 | +## Layout |
| 8 | + |
| 9 | +``` |
| 10 | +tools/release/ |
| 11 | +├── bin/ CLI entrypoints (one Symfony Console SingleCommandApplication per file) |
| 12 | +├── src/ Service classes + value objects (PSR-4: OpenEMR\Release\) |
| 13 | +├── tests/ PHPUnit tests + in-memory fakes (PSR-4: OpenEMR\Release\Tests\) |
| 14 | +├── contracts/ JSON schemas vendored across consumer repos |
| 15 | +├── scripts/ Operational shell probes (App-token sanity checks etc.) |
| 16 | +├── templates/ PR body / changelog Twig templates |
| 17 | +├── Taskfile.yml Glue between workflows and the PHP CLIs (`task release:*`) |
| 18 | +└── versions.yml The 3-slot rotation registry (current/next/dev) |
| 19 | +``` |
| 20 | + |
| 21 | +Workflow steps in `.github/workflows/` are deliberately thin: mint App token, |
| 22 | +checkout, run `task release:<name>`. All decision logic lives in PHP so it can |
| 23 | +be unit-tested. |
| 24 | + |
| 25 | +## Conventions |
| 26 | + |
| 27 | +- **PHP 8.5**, `declare(strict_types=1)`, **PHPStan level 10 + strict-rules**, PSR12, license-header docblock on every file. |
| 28 | +- **Service classes** are `final readonly class` with constructor-promoted DI. They shell out via `Symfony\Component\Process\Process::mustRun()` and throw `\RuntimeException` on failure. |
| 29 | +- **Result objects** are `final readonly class` value objects exposing public promoted properties + a small predicate (`isValid()`, `isReady()`, `wasSuccessful()`). Methods return result objects rather than associative arrays so the call site is type-safe and tests can assert on shape. |
| 30 | +- **gh as the network layer.** Network calls go through the `gh` CLI, which uses the ambient `GH_TOKEN` env var. Workflows mint a release App token via `actions/create-github-app-token@v1` and export it as `GH_TOKEN`. CLI authors don't handle auth themselves. |
| 31 | +- **Pure helpers stay static.** Methods that don't need shell or network (string builders, predicate logic, JSON shape interpretation) are kept `static` so unit tests can exercise them directly. |
| 32 | +- **Tests** live in `tests/` mirroring `src/` flat. HTTP-dependent classes are not unit-tested — pure helpers and orchestrators (with fake collaborators) are. See `tests/Fakes/` for the in-memory test doubles. |
| 33 | + |
| 34 | +## Why are some of these classes so verbose? |
| 35 | + |
| 36 | +A "wrap `gh` and merge a few PRs" service can read as ~600 lines of PHP before any meaningful logic shows up. That's not accidental: |
| 37 | + |
| 38 | +- Every file pays a ~13-line tax for the license-header docblock + `declare(strict_types=1)` + namespace + class declaration. |
| 39 | +- Each gh response gets a typed PHPDoc shape (`array{...}`) so PHPStan level 10 can verify field access. That's 5–15 lines per response shape, not per call. |
| 40 | +- We use one **value object per result** (`PullRequestSnapshot`, `PullRequestReadiness`, `ShipReleaseStepResult`, etc.) instead of returning arrays. Roughly 30 lines per object — the price of keeping the type system honest at every layer boundary. |
| 41 | +- Service classes that touch `gh` get an **interface + concrete impl + in-memory fake** (`PullRequestApi` / `GhPullRequestApi` / `FakePullRequestApi`). The interface seam roughly doubles the API-facing line count but is what makes the orchestrator unit-testable without a real GitHub. |
| 42 | +- Defensive logic (preflight, ordering enforcement, downstream-wait, status retraction, base validation, exception handling per step) accumulates over review iterations. Each piece is small and justified individually; together they dominate the orchestrator file. |
| 43 | + |
| 44 | +The smaller alternatives: |
| 45 | +- A bash script invoking `gh pr view --json … | jq` would be ~200 lines, ship the happy path, and be brittle on every edge case the orchestrator now defends against. No tests. |
| 46 | +- A single PHP file with no value objects, no interface, no fakes would land near 350 lines but cap at "trust live runs to verify." |
| 47 | + |
| 48 | +The current shape is the cost of **PHPStan level 10 + strict tests + structured failure reporting**. Worth knowing before adding the next CLI. |
| 49 | + |
| 50 | +## Adding a new CLI |
| 51 | + |
| 52 | +1. Add `src/<Service>.php` (`final readonly class`, ctor-promoted DI). If it touches the network, define an interface and a concrete `Gh*` impl so tests can substitute a fake. |
| 53 | +2. Add `src/<Service>Result.php` if it returns more than a primitive. |
| 54 | +3. Add `bin/<command>.php` (Symfony Console `SingleCommandApplication`). Keep all logic in the service class; the bin file is just option parsing + result rendering. |
| 55 | +4. Add a `release:<command>` entry in `Taskfile.yml` with `requires.vars` and `{{shellQuote .VAR}}` for every parameter. |
| 56 | +5. Add tests in `tests/` — pure helpers tested directly, services tested via fake collaborators. |
| 57 | +6. Add a workflow step that mints the App token and runs `task release:<command>`. |
| 58 | +7. After merge, append the corresponding `Bash(task -d ~/dev/oce/...)` permission to `~/.claude/settings.json` (alphabetical). |
| 59 | + |
| 60 | +## Running checks locally |
| 61 | + |
| 62 | +```sh |
| 63 | +composer test # PHPUnit |
| 64 | +composer phpstan # level 10 + strict-rules |
| 65 | +composer phpcs # PSR12 + line-length |
| 66 | +composer rector-check # dry-run modernization checks |
| 67 | +composer require-checker # composer.json declares every used symbol |
| 68 | +composer check # all five |
| 69 | +composer fix # phpcbf + rector-fix |
| 70 | +``` |
0 commit comments