Skip to content

Commit aca0cd4

Browse files
authored
feat(release): add ship-release workflow to merge three release PRs in order (#712)
Closes #705. ## Summary Adds a manually-triggered workflow (`ship-release.yml`) that orchestrates merging the three sibling release PRs in strict order — infra (`openemr-devops`) → conductor (`openemr/openemr`) → docs (`website-openemr`) — with mergeability gates on each step. Replaces three browser tabs and three required-checks waits with one button press, and removes the partial-merge risk documented in `openemr/openemr` `docs/RELEASE_PROCESS.md`. ## What's in the box - **`.github/workflows/ship-release.yml`** — `workflow_dispatch` with `version`, `rel_branch`, `dry_run`, `timeout_seconds` inputs. Mints one App token scoped to all three repos; `permissions: {}`; `concurrency: ship-release`. - **`tools/release/src/ShipReleaseOrchestrator.php`** — strict-order merge, skip-if-merged (so the same trigger handles partial-merge recovery), blocked-stops-the-line (no partial merges originate here), `release/ship-approved` commit status posted before each merge, conductor→docs head-SHA polling with timeout, docs-first detection. - **`tools/release/src/PullRequestApi.php`** + **`GhPullRequestApi.php`** — narrow cross-repo PR interface (findByHead / getReadiness / postCommitStatus / squashMerge with `--match-head-commit`) over the `gh` CLI; kept separate from `GitHubApi.php` to stay testable and focused. - **`tools/release/bin/ship-release.php`** — Symfony Console CLI (matches existing `bin/` shape), wired through Taskfile as `release:ship`. - **Result/value objects + `Clock` test seam** — `PullRequestTarget`, `PullRequestSnapshot`, `PullRequestReadiness`, `ShipReleaseStepResult`/`Status`, `ShipReleaseResult`, `ShipReleaseRenderer`. - **Tests** — `FakePullRequestApi` + `FakeClock`; covers happy path, infra-already-merged skip, conductor-blocked-stops-docs, docs-first fatal (refuses to merge anything), dry-run, downstream-wait timeout. 12 new tests, 62 total. ## Recovery behavior Re-running the workflow handles partial-merge recovery automatically because every merged step is detected as `SKIPPED_ALREADY_MERGED` and the run continues with whatever's left. The one case that can't be recovered automatically — docs merged before conductor — is detected up front and the workflow refuses to do anything, matching what issue #705 calls out as the manual reconciliation case. ## Test plan - [x] `composer test` (62 tests, 171 assertions) - [x] `composer phpstan` (level 10 + strict-rules, no errors) - [x] `composer phpcs` (clean) - [x] `composer rector-check` (clean) - [x] `actionlint .github/workflows/ship-release.yml` (clean) - [ ] First real use: dry-run against the next live release-prep PRs to confirm readiness reporting matches reality before doing a real merge. ## Out of scope (per #705) - Configuring branch protection on the three base branches to require `release/ship-approved` (separate repo-admin step; this PR only emits the status so admins *can* enable it). - Docs-side reconciliation for the docs-first-merged case. - Auto-rollback on partial failure.
1 parent a6aa514 commit aca0cd4

28 files changed

Lines changed: 2265 additions & 2 deletions

.github/workflows/ship-release.yml

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
name: Ship Release
2+
3+
# Manually-triggered orchestration for the three-PR release flow (#705):
4+
# merges infra → conductor → docs in strict order, skipping any already merged
5+
# (so the same trigger handles partial-merge recovery), and refusing to merge
6+
# anything if any unmerged PR is not ready. Mints one App token with PR-write
7+
# on all three repos and posts a release/ship-approved commit status on each
8+
# PR head before merging it (so branch protection can require it later).
9+
10+
on:
11+
workflow_dispatch:
12+
inputs:
13+
version:
14+
description: 'Release version (e.g. 8.1.0) — picks website-openemr branch release-docs/<version>'
15+
required: true
16+
type: string
17+
rel_branch:
18+
description: 'Release branch (e.g. rel-810) — picks openemr/openemr branch release-prep/<rel_branch>'
19+
required: true
20+
type: string
21+
dry_run:
22+
description: 'Check readiness only — do not merge or post statuses'
23+
required: false
24+
type: boolean
25+
default: false
26+
timeout_seconds:
27+
description: 'Max seconds to wait for docs PR to update after conductor merge'
28+
required: false
29+
type: string
30+
default: '600'
31+
32+
permissions: {}
33+
34+
concurrency:
35+
group: ship-release
36+
cancel-in-progress: false
37+
38+
jobs:
39+
ship:
40+
name: Merge release PRs in order
41+
runs-on: ubuntu-24.04
42+
env:
43+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
44+
steps:
45+
- name: Mint release App token
46+
id: app-token
47+
uses: actions/create-github-app-token@v1
48+
with:
49+
app-id: ${{ secrets.RELEASE_APP_ID }}
50+
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
51+
owner: openemr
52+
repositories: |
53+
openemr
54+
openemr-devops
55+
website-openemr
56+
57+
- name: Checkout
58+
uses: actions/checkout@v6
59+
with:
60+
token: ${{ steps.app-token.outputs.token }}
61+
62+
- name: Setup PHP
63+
uses: shivammathur/setup-php@v2
64+
with:
65+
php-version: '8.5'
66+
67+
- name: Install Task
68+
uses: arduino/setup-task@v2
69+
70+
- name: Ship release
71+
working-directory: tools/release
72+
env:
73+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
74+
VERSION: ${{ inputs.version }}
75+
REL_BRANCH: ${{ inputs.rel_branch }}
76+
TIMEOUT_SECONDS: ${{ inputs.timeout_seconds }}
77+
DRY_RUN: ${{ inputs.dry_run && '1' || '0' }}
78+
STATUS_TARGET_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
79+
run: >-
80+
task release:ship
81+
VERSION="${VERSION}"
82+
REL_BRANCH="${REL_BRANCH}"
83+
TIMEOUT_SECONDS="${TIMEOUT_SECONDS}"
84+
STATUS_TARGET_URL="${STATUS_TARGET_URL}"
85+
DRY_RUN="${DRY_RUN}"

tools/release/README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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+
```

tools/release/Taskfile.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,20 @@ tasks:
222222
- git commit -m {{shellQuote .COMMIT_MESSAGE}}
223223
- git push --force-with-lease origin {{shellQuote .ROTATION_BRANCH}}
224224

225+
release:ship:
226+
desc: Merge the three release PRs (infra → conductor → docs) in order (issue #705)
227+
requires:
228+
vars: [VERSION, REL_BRANCH]
229+
deps: [setup]
230+
cmds:
231+
- >-
232+
php bin/ship-release.php
233+
--version={{shellQuote .VERSION}}
234+
--rel-branch={{shellQuote .REL_BRANCH}}
235+
{{if .TIMEOUT_SECONDS}}--timeout-seconds={{shellQuote .TIMEOUT_SECONDS}}{{end}}
236+
{{if .STATUS_TARGET_URL}}--status-target-url={{shellQuote .STATUS_TARGET_URL}}{{end}}
237+
{{if eq .DRY_RUN "1"}}--dry-run{{end}}
238+
225239
release:probe-app-installation:
226240
desc: Verify the release App is installed on $OWNER/$REPO_NAME
227241
cmds:

tools/release/bin/ship-release.php

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#!/usr/bin/env php
2+
<?php
3+
4+
/**
5+
* Merge the three release PRs (infra → conductor → docs) in order.
6+
*
7+
* Authenticates via the ambient GH_TOKEN env var. The workflow mints a release
8+
* App token with PR-write on all three repos and exports it before invoking.
9+
*
10+
* @package openemr-devops
11+
* @link https://www.open-emr.org
12+
* @author Michael A. Smith <michael@opencoreemr.com>
13+
* @copyright Copyright (c) 2026 OpenCoreEMR Inc.
14+
* @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3
15+
*/
16+
17+
declare(strict_types=1);
18+
19+
require dirname(__DIR__) . '/vendor/autoload.php';
20+
21+
use OpenEMR\Release\GhPullRequestApi;
22+
use OpenEMR\Release\PullRequestTarget;
23+
use OpenEMR\Release\ShipReleaseOptions;
24+
use OpenEMR\Release\ShipReleaseOrchestrator;
25+
use OpenEMR\Release\ShipReleaseRenderer;
26+
use OpenEMR\Release\SystemClock;
27+
use Symfony\Component\Console\Input\InputInterface;
28+
use Symfony\Component\Console\Input\InputOption;
29+
use Symfony\Component\Console\Output\OutputInterface;
30+
use Symfony\Component\Console\SingleCommandApplication;
31+
32+
(new SingleCommandApplication())
33+
->setName('ship-release')
34+
->setDescription('Merge the three release PRs in order (issue #705)')
35+
->addOption('version', null, InputOption::VALUE_REQUIRED, 'Release version (e.g. 8.1.0)')
36+
->addOption('rel-branch', null, InputOption::VALUE_REQUIRED, 'Release branch name (e.g. rel-810)')
37+
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Check readiness without merging or posting status')
38+
->addOption(
39+
'timeout-seconds',
40+
null,
41+
InputOption::VALUE_REQUIRED,
42+
'Max seconds to wait for docs PR to update after conductor merges',
43+
'600',
44+
)
45+
->addOption('status-target-url', null, InputOption::VALUE_REQUIRED, 'target_url for the ship-approved status', '')
46+
->setCode(function (InputInterface $input, OutputInterface $output): int {
47+
$version = ShipReleaseOptions::asString($input, 'version');
48+
$relBranch = ShipReleaseOptions::asString($input, 'rel-branch');
49+
if ($version === '' || $relBranch === '') {
50+
$output->writeln('<error>--version and --rel-branch are required</error>');
51+
return 1;
52+
}
53+
$timeoutRaw = ShipReleaseOptions::asString($input, 'timeout-seconds');
54+
if (!ctype_digit($timeoutRaw) || (int) $timeoutRaw < 1) {
55+
$output->writeln('<error>--timeout-seconds must be a positive integer</error>');
56+
return 1;
57+
}
58+
59+
$orchestrator = new ShipReleaseOrchestrator(
60+
new GhPullRequestApi(),
61+
new SystemClock(),
62+
(int) $timeoutRaw,
63+
(bool) $input->getOption('dry-run'),
64+
ShipReleaseOptions::asString($input, 'status-target-url'),
65+
);
66+
$result = $orchestrator->ship(PullRequestTarget::forRelease($version, $relBranch));
67+
ShipReleaseRenderer::render($output, $result);
68+
return $result->wasSuccessful() ? 0 : 1;
69+
})
70+
->run();

tools/release/composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"license": "GPL-2.0-or-later",
66
"require": {
77
"php": "^8.5",
8+
"ext-mbstring": "*",
89
"nikic/php-parser": "^5.0",
910
"symfony/console": "^7.0",
1011
"symfony/process": "^7.0",
@@ -42,6 +43,7 @@
4243
"@phpcs",
4344
"@phpstan",
4445
"@rector-check",
46+
"@require-checker",
4547
"@test"
4648
],
4749
"fix": [

tools/release/composer.lock

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tools/release/src/Clock.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
/**
4+
* Test seam for the orchestrator's downstream-effect polling loop.
5+
*
6+
* @package openemr-devops
7+
* @link https://www.open-emr.org
8+
* @author Michael A. Smith <michael@opencoreemr.com>
9+
* @copyright Copyright (c) 2026 OpenCoreEMR Inc.
10+
* @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3
11+
*/
12+
13+
declare(strict_types=1);
14+
15+
namespace OpenEMR\Release;
16+
17+
interface Clock
18+
{
19+
public function now(): \DateTimeImmutable;
20+
21+
public function sleep(int $seconds): void;
22+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
/**
4+
* Outcome of refreshing the docs PR snapshot + readiness right before merge.
5+
*
6+
* Replaces an earlier 4-tuple with overloaded null semantics. Only one of the
7+
* three static factories produces a usable instance; the type system enforces
8+
* which fields are populated.
9+
*
10+
* @package openemr-devops
11+
* @link https://www.open-emr.org
12+
* @author Michael A. Smith <michael@opencoreemr.com>
13+
* @copyright Copyright (c) 2026 OpenCoreEMR Inc.
14+
* @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3
15+
*/
16+
17+
declare(strict_types=1);
18+
19+
namespace OpenEMR\Release;
20+
21+
final readonly class DocsRefreshResult
22+
{
23+
/**
24+
* @param list<string> $blockingReasons
25+
*/
26+
private function __construct(
27+
public ?PullRequestSnapshot $snapshot,
28+
public ?PullRequestReadiness $readiness,
29+
public ?string $stopReason,
30+
public array $blockingReasons,
31+
) {
32+
}
33+
34+
public static function success(PullRequestSnapshot $snapshot, PullRequestReadiness $readiness): self
35+
{
36+
return new self($snapshot, $readiness, null, []);
37+
}
38+
39+
/**
40+
* @param list<string> $reasons
41+
*/
42+
public static function blocked(?PullRequestSnapshot $snapshot, string $stopReason, array $reasons): self
43+
{
44+
return new self($snapshot, null, $stopReason, $reasons);
45+
}
46+
47+
public function isSuccess(): bool
48+
{
49+
return $this->stopReason === null;
50+
}
51+
}

0 commit comments

Comments
 (0)