Skip to content

docs(release): add release-mechanism gaps + open questions doc [DRAFT]#12599

Draft
bradymiller wants to merge 16 commits into
openemr:masterfrom
bradymiller:release-mechanism-gaps-doc
Draft

docs(release): add release-mechanism gaps + open questions doc [DRAFT]#12599
bradymiller wants to merge 16 commits into
openemr:masterfrom
bradymiller:release-mechanism-gaps-doc

Conversation

@bradymiller

Copy link
Copy Markdown
Member

Summary

Draft companion to PR #12598 (the release-mechanism migration
plan). Adds a working-notes doc tracking gaps + manual procedures
in the current release-mechanism — the canonical place to capture
surprises, manual workarounds, timing nuances, and the "this is how
we actually do X right now" procedures that emerge during release
work.

What's in here

File Lines
docs/release-mechanism-gaps.md 781

The doc covers:

  • Quick context — current branch under release work, conductor
    location, patch-flow stance.
  • 5 identified gaps:
    • G1--skip-globals flag on the conductor (why is
      GlobalsIncMutator disabled in production runs?)
    • G2 — post-release version bump to next-dev on rel branch is
      unwired (no auto-bump after a tag ships)
    • G3SqlUpgradeSkeletonMutator is master-only AND
      currently has no workflow path
    • G4 — no workflow invocation path for --scope=master
      (cross-tracked in migration plan's deferred-debt)
    • G5 — no auto-trigger on rel-* branch creation (cross-tracked
      in migration plan's deferred-debt)
  • Master-side branch-cut procedure — full 7-step manual
    checklist for cutting a new rel branch from master, capturing the
    easy-to-forget Dockerfile two-block footgun
  • Per-release-on-rel-branch docker upgrade procedure — mandatory
    per release (this IS the consumer auto-upgrade feature),
    cross-branch propagation requirement, ~5-substitution template
    pattern for new fsupgrade-N.sh
  • SQL upgrade script procedure — master file-rename dance for
    intermediate-minor cases
  • Docker Hub tag modeldev / next / latest are
    version-names (not floating pointers), with the slot-promotion
    pattern that fires when a release ships
  • openemr_version_ref branch-tip-vs-tag-pin lifecycle across a
    release
  • Consolidated timing picture — who does what, when (conductor
    vs manual × release time vs cut time)
  • Canonical 8.1.1 release sequence (P1-P8) — the full
    end-to-end PR flow
  • Things-to-verify checklist for the upcoming 8.1.1 manual prep
  • Decisions-made (don't re-litigate) list
  • Update log

Why a draft PR

Visibility for the community + other maintainers' AI assistants who
might be examining release-mechanism work. The gap-tracking content
is substantive enough to warrant the formal-review surface that a PR
provides. Expected to be a living doc — gap discoveries land via
normal PRs against master as they emerge.

Test plan

🤖 Generated with Claude Code

Working notes tracking open gaps + manual procedures in the current
release-mechanism. Pairs with PR openemr#12598 (the migration plan).
Pre-publication draft.

Assisted-by: Claude Code
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4fba1c3e-6f04-4f46-9ba0-9c52e5166d93

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jun 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 25.05%. Comparing base (30226e9) to head (d051564).
⚠️ Report is 112 commits behind head on master.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@             Coverage Diff              @@
##             master   #12599      +/-   ##
============================================
+ Coverage     24.87%   25.05%   +0.17%     
- Complexity    84366    84997     +631     
============================================
  Files          3887     3914      +27     
  Lines        417119   419966    +2847     
============================================
+ Hits         103759   105206    +1447     
- Misses       313360   314760    +1400     
Flag Coverage Δ
apache 15.28% <ø> (-0.22%) ⬇️
api 3.70% <ø> (-0.12%) ⬇️
api-tests 3.70% <ø> (-0.09%) ⬇️
e2e 8.83% <ø> (-0.41%) ⬇️
e2e-tests 8.83% <ø> (-0.41%) ⬇️
email 0.18% <ø> (+<0.01%) ⬆️
isolated-php8.2 9.10% <ø> (+0.59%) ⬆️
isolated-php8.3 9.10% <ø> (+0.59%) ⬆️
isolated-php8.4 9.10% <ø> (+0.59%) ⬆️
isolated-php8.5 9.10% <ø> (+0.59%) ⬆️
isolated-php8.6 9.10% <ø> (+0.59%) ⬆️
mariadb10.11.18 ?
mariadb10.6.27 ?
mariadb11.4.12 ?
mariadb11.8.8 15.28% <ø> (-0.44%) ⬇️
mariadb12.2.2 ?
mysql5.7.44 ?
mysql8.0.46 ?
mysql9.3.0 ?
nginx ?
php8.2 15.28% <ø> (-0.18%) ⬇️
php8.3 ?
php8.4 ?
php8.5 ?
services 5.26% <ø> (-0.05%) ⬇️
upgrade ?

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Brings PR openemr#12599's working copy in sync with the standalone
working notes (which had been kept ahead during 8.1.1 release
prep + recovery work on 2026-06-23).

Adds four new gaps surfaced during 8.1.1 prep:

- **G7 — Conductor tooling drift on pre-820 rel branches.**
  rel-810/800/704 carry per-branch copies of the conductor's PHP
  + workflows that silently diverge from master. Discovered live
  when rel-810's old static BranchVersionResolver::branchToVersion()
  returned wrong version for conductor dispatch (fixed in openemr#12611).
  Full conductor sync from master to all pre-820 rel branches
  is the structural fix; recommended after 8.1.1 ships.

- **G8 — No automated regression test for conductor resolvers.**
  BranchVersionResolver + DispatchDataBuilder + others have no
  isolated PHPUnit coverage. The G7 bug shipped silently because
  nothing caught 'returns wrong version for rel-810' before live
  fire. Concrete test surface enumerated; complements G7 — running
  the suite on each branch's CI catches future drift automatically.

- **G9 — release-docs/<version> PRs on website-openemr don't
  supersede across version changes.** Each conductor re-resolution
  to a new version opens a fresh PR at the new branch and leaves
  the prior orphaned. Two fix options sketched; per-rel-branch
  head naming (matching openemr/openemr's release-prep/rel-810
  pattern) is the structurally cleaner answer.

- **G10 — Reusable workflows as a replacement for the
  byte-identical canary on the docker pipeline.** Captured as a
  future consideration. If starting today, reusable workflows
  would be the structurally cleaner answer (master owns impl, rel
  branches carry thin caller stubs, drift impossible by
  construction). Canary works and is production-validated, so
  not urgent; natural time to revisit is when a 'this would have
  been easier with single-source-of-truth' moment hits the docker
  side.

Assisted-by: Claude Code
…g out)

Per maintainer (2026-06-23): rel-800 and rel-704 will not get
BranchVersionResolver / conductor tooling backports because both
branches will rotate out — no future releases planned on either.
Latent drift bugs on those branches will never fire.

G7 (drift) scope shrinks to rel-810 only. Full conductor sync
recommended after 8.1.1 ships and before the 8.1.2 cycle. If a
need ever surfaces to ship from rel-800/rel-704 before retirement,
that triggers a per-branch surgical backport at that time, not
pre-emptive sync now.

G8 (regression tests) scope correspondingly narrows: tests added
on master + carried to rel-810 via the G7 sync, rather than to
all pre-820 rel branches.

Assisted-by: Claude Code
…l paths

Adds a pre-ship blocker note to the 'P5. Merge the release-prep PR'
section of the canonical 8.1.1 release sequence:

The current Infra PR slot (openemr-devops PR openemr#760,
release-rotation/auto) is frozen with pre-docker-migration content
from 2026-06-10. Its diff edits docker/openemr/8.0.0/Dockerfile,
docker/openemr/current, and dependabot.yml references to
docker/openemr/* -- all paths now deleted on devops master (404).

release-rotation.yml runs successfully on every dispatch but skips
force-push + PR-update because the diff against the already-rotated
branch is empty. PR openemr#760 stays frozen indefinitely.

Shipping 8.1.1 via ship-release.yml today would either:
1. Block on PR openemr#760's unmergeable state (preflight refuses to merge)
2. Or silently undo the docker migration by resurrecting deleted paths

Workstream 1 of the migration doc (delete rotation slice + collapse
ship-release to 2-PR shape) unblocks this path. Discovered 2026-06-24
while tracing the ship flow end-to-end.

Also adds:

- 'Recommended path' (ship-release.yml) vs 'Direct path' (merge
  conductor PR directly) options for P5, with tradeoffs (direct path
  sidesteps the blocker but loses multi-PR-merge ordering safety net)
- Ordering constraints summary updated to note P5 blocked on
  workstream 1

Assisted-by: Claude Code
…2656

PR openemr#12656 reverses the 2026-06-23 decision that
release-targets.yml allows only one row per branch. Multi-row IS
needed for the in-dev patch release case: when a rel branch hits
a new patch in dev mode (e.g., rel-810 -> 8.1.2-dev), the daily
orchestrator still needs to publish the prior stable (8.1.1) until
the new dev ships. One row per branch can't express that.

PR drops the validator's branch-uniqueness check (the
duplicate-docker_tag check stays — push race on Docker Hub still
matters). Adds optional 'unreleased: true' row marker for
placeholders; orchestrator skip-on-unreleased wired in same PR;
dockerhub readme push + demo_farm bumper to follow.

Example row added in openemr#12656: second rel-810 entry with
docker_tags: 8.1.0 + openemr_version_ref: v8_1_0 +
unreleased: true. Wires up the multi-row mechanism without
immediately publishing 8.1.0 daily images.

Decisions-made section + update log both touched.

Assisted-by: Claude Code
Three updates capturing the design discussion of 2026-06-27 around
the branch-cut + release-time extensions:

G4 — folds into G5's branch-cut automation design instead of
exposing scope as a release-prep.yml input. Keeps release-prep.yml's
scope narrow (rel-branch-only); the new branch-cut-automation.yml
workflow invokes the master-scope mutator as one of its steps.

G5 — expanded with full design. on: create trigger fires once on
rel-NNN0 branch creation; opens two coordinated PRs (rel-NNN0-side
Dockerfile ARG edit + master-side version.php advance + add
release-targets row + rotate next + SQL skeleton chain adjust).
Notes the conductor's premature release-prep PR interaction and
the recommendation to accept it (vs add suppression complexity).

G11 (new) — Post-release release-targets.yml updates are manual.
Extend conductor to open a SECOND PR (release-finalize/<rel-branch>)
on master paired with the existing release-prep/<rel-branch> PR.
Carries the slot shuffle, ref pin, and unreleased-row drop. Lands
on tag dispatch consumer marking it Ready and auto-merging.

Both G5 and G11 map to workstreams 2 (branch-cut) and 3 (per-release
optimization) in the planning doc. Implementation work captured
there in workstream-specific subsections.

Assisted-by: Claude Code
Captures the 2026-06-27 design discussion that meaningfully reshapes
G6 from from-scratch render to reconciliation. Cluster identity is
sticky across runs because each cluster name maps to a subdomain
(eight -> eight.openemr.io) that's referenced from external surfaces
(wiki, social, mail). Reassigning a cluster's meaning would break
those external references, so the bot reads the CURRENT
ip_map_branch.txt as state, applies a reconciliation diff against
the desired state computed from upstream, and writes the new file
preserving cluster -> subdomain stability.

Key refinements:

- Per-input source map: release-targets.yml is master-only;
  Dockerfiles are per-branch; current demo_farm files are read for
  reconciliation state
- Section ownership matrix: Production + Up-for-grabs are fixed
  cluster identities; Master demos + Release demos are sticky from
  prior state with new ones taken from parked; Parked is the
  overflow bench; Misc is hand-curated (bot never touches)
- Up-for-grabs flex always from master's Dockerfile, regardless of
  community-claimed branch override
- Cluster count derivation: from current per-cluster state
  (preserves production=3, up-for-grabs=3, rest=2 pattern); default
  2 for newly-assigned
- Fail-loud edge cases: no latest holder, empty parked bench when
  cluster needed, Dockerfile ARG parse failure, CI matrix path
  missing
- Reconciliation algorithm sketch (6 steps from inputs to PR)
- Trigger composition: daily cron (load-bearing self-healing) +
  eager repository_dispatch (optional optimization) + workflow_dispatch
  (manual / dry-run)
- Implementation language: bash + yq + curl + awk (demo_farm self-
  contained, low contributor barrier)
- Coordination boundary with workstreams 2 + 3:
  release-targets.yml is the contract; no tight coupling
- Three-PR scaffolding plan: dry-run -> live PR -> eager dispatch
  + unreleased skip

The unreleased-skip in PR #3 is the demo_farm-side consumer of the
openemr#12656 marker that's currently tracked as the
remaining follow-up.

Assisted-by: Claude Code
…ior bumper)

Surveyed demo_farm_openemr today and found the prior automation
consists of more than just bump-tag.yml: there's a whole
tools/release/ PHP project (IpMapBumper class + CLI + tests +
toolchain scaffolding) that exists only to support bump-tag.yml.
All of it becomes dead code after the new bot's dispatch consumers
land.

PR #3 expanded to atomic flip:

Add:
- repository_dispatch consumers (openemr-tag / openemr-rel-cut /
  openemr-rel-update)
- unreleased: true skip on release-targets.yml rows

Delete:
- .github/workflows/bump-tag.yml (109 lines)
- tools/release/src/IpMapBumper.php + bin/bump-ip-map.php +
  tests/IpMapBumperTest.php
- tools/release/{Taskfile.yml, phpunit.xml.dist, phpcs.xml,
  phpstan.neon, rector.php, composer.json, composer.lock,
  .gitignore} — entire PHP toolchain scaffolding since the new
  bot is bash and there are no other PHP tools in demo_farm

Net: ~200 LOC + ~10 config files deleted; ~50 LOC added for the
new dispatch wiring.

Atomic-ness rationale:

- Retiring BEFORE PR #3 -> gap window where openemr-tag dispatches
  don't update demo_farm
- Retiring AFTER PR #3 -> race condition (both workflows fire on
  every openemr-tag dispatch)
- Atomic -> one dispatch consumer at all times, clean cutover

Future bash tools live in their own dir; future PHP tools (if
ever) re-scaffold their own project — keeping empty scaffolding is
bitrot bait.

Assisted-by: Claude Code
bradymiller added a commit that referenced this pull request Jul 1, 2026
…r (workstream 3 Phase A) (#12662)

## Context (for anyone catching up)

This is part of the broader **release-mechanism migration** project. Two
living tracking docs describe the larger picture and are required
reading for context:

- **Planning doc** (still in DRAFT PR #12598):
[`docs/release-mechanism-migration-from-devops.md`](https://github.com/bradymiller/openemr/blob/release-mechanism-migration-doc/docs/release-mechanism-migration-from-devops.md)
— 6 workstreams of release infrastructure work. See the "**Workstream 3
detail — release-time partner PR + release-cycle-bot**" section for what
this PR fits into. Workstreams 1 + 5 (devops cleanup + demo_farm
auto-derive bot) already shipped 2026-06-28; workstream 4 (8.1.1 ship
from rel-810) is the next actual release event.
- **Gaps doc** (still in DRAFT PR #12599):
[`docs/release-mechanism-gaps.md`](https://github.com/bradymiller/openemr/blob/release-mechanism-gaps-doc/docs/release-mechanism-gaps.md)
— open questions + design notes for each gap. See the "**G11 —
Post-release release-targets.yml updates are manual** *(workstream 3
design)*" section, especially the "Phase A refinement (2026-06-28)"
subsection that drove this PR's exact scope.

**Existing baseline**: the conductor (`release-prep.yml`) lives in every
branch and fires on push to `rel-*`. It opens a tracking PR titled
`release-prep/<rel-branch>` against the rel branch, force-updated on
every push, merged at ship-time. This PR extends that conductor to ALSO
open a master-side partner PR (`release-finalize/<rel-branch>`) at the
same trigger.

## Summary

Phase A of workstream 3 (G11). Automates the three coordinated edits
master's `.github/release-targets.yml` needs after a rel branch ships,
as a draft `release-finalize/<rel-branch>` partner PR opened alongside
the existing `release-prep/<rel-branch>` PR.

The three transforms (today done by hand for every release):

- Pin the rel branch row's `openemr_version_ref` to the new tag (e.g.
`rel-810` -> `v8_1_1`) so daily docker builds stop tracking the branch
tip
- Slot-shuffle Docker Hub `latest`/`next`/`dev` across rows:
just-shipped rel branch promotes `next` -> `latest`, prior `latest`
holder drops it, next-upcoming-stable owner (newer rel branch if any
else master) acquires `next`
- Drop the unreleased placeholder row from the multi-row dev pattern
(#12656) when present

## What's in this PR

- New mutator `PostReleaseTargetsMutator` (line-based surgical edits to
preserve the file's substantial human-authored comments; idempotent;
Symfony YAML parser used as post-edit sanity check)
- `MutatorContext` gains an optional `relBranch` field + `tagName()`
helper; existing mutators ignore it
- `ReleasePrepCommand` gains `--rel-branch` (required for
`--scope=master`), plumbs to context, rewires master-scope list to a
single `PostReleaseTargetsMutator`
- `VersionPhpMasterMutator` + `SqlUpgradeSkeletonMutator` stay defined
but intentionally unwired — they are branch-cut concerns workstream 2
(G4) will own
- `.github/workflows/release-prep.yml` grows steps after the existing
prep-PR step (checkout master / composer install in master-checkout /
run master-scope mutators / open `release-finalize/<rel-branch>` via
peter-evans, draft, test-mode skipped)
- `render-pr-body.php` learns `--rel-branch` for the new finalize
template

## Lifecycle

1. Conductor fires on push to rel-* -> opens BOTH
`release-prep/<rel-branch>` (rel-*) AND `release-finalize/<rel-branch>`
(master) as drafts
2. Operator marks the rel-branch PR Ready and merges, triggering the tag
3. Operator marks the finalize PR Ready and merges (auto-mark-Ready on
tag creation can land in a follow-up)

## Sequencing with Phase B

Phase B (separate PR) cherry-picks the conductor extension to `rel-810`
so the upcoming 8.1.1 release benefits — rel-810's conductor invokes its
own `src/Common/Command/...` tree, not master's, so both the workflow
YAML and the PHP changes must land there.

## References

- **Gaps doc G11** (DRAFT PR #12599): [link to
file](https://github.com/bradymiller/openemr/blob/release-mechanism-gaps-doc/docs/release-mechanism-gaps.md)
— especially the "Phase A refinement (2026-06-28)" subsection and the
"Docker Hub tag model" / slot-promotion model section
- **Planning doc Workstream 3 detail** (DRAFT PR #12598): [link to
file](https://github.com/bradymiller/openemr/blob/release-mechanism-migration-doc/docs/release-mechanism-migration-from-devops.md)
- The dual-PR partner pattern generalizes — workstream 2 (G4/G5) will
reuse the extended `MutatorContext` + dual-checkout workflow structure
for branch-cut automation

## Dry-run output

Run against the live `.github/release-targets.yml` with
`--target-version=8.1.1 --rel-branch=rel-810 --scope=master`:

```diff
@@ -99,30 +99,19 @@
   # release cycle (e.g., when version.php switches to 8.3.0-dev).
   # The docker_tag <-> version.php alignment guard in
   # docker-validate-release-targets.yml catches drift on PRs.
-  docker_tags: 8.2.0,dev
+  docker_tags: 8.2.0,dev,next
   openemr_version_ref: master
 
 - branch: rel-810
-  docker_tags: 8.1.1,next
-  openemr_version_ref: rel-810
-
-# Second rel-810 row -- placeholder for the multi-row dev pattern (see
-# `unreleased` field doc above). Wires up the second-row mechanism
-# without actually publishing 8.1.0 from this configuration. When a real
-# dev cycle on rel-810 starts (e.g., 8.1.2-dev with 8.1.1 already
-# shipped), this row gets repurposed: docker_tags -> 8.1.1,
-# openemr_version_ref -> v8_1_1, `unreleased` flag dropped.
-- branch: rel-810
-  docker_tags: 8.1.0
-  openemr_version_ref: v8_1_0
-  unreleased: true
+  docker_tags: 8.1.1,latest
+  openemr_version_ref: v8_1_1
 
 - branch: rel-800
   # 8.0.0 is the floating "current latest 8.0.0.x" pointer (always advances
   # with openemr_version_ref); 8.0.0.3 is the specific-patch pointer (only
   # advances when this row bumps to 8.0.0.4+). Both tags get an auto-appended
   # "<tag>-YYYY-MM-DD" sibling for immutable per-build pinning.
-  docker_tags: 8.0.0,8.0.0.3,latest
+  docker_tags: 8.0.0,8.0.0.3
   openemr_version_ref: v8_0_0_3
 
 - branch: rel-704
```

Second invocation is a no-op (idempotency confirmed).

## Test plan

- [x] PHPStan level 10 clean on changed files
- [x] PHPCS clean on changed files
- [x] Rector dry-run clean on changed files
- [x] phpunit-isolated: PostReleaseTargets + ReleasePrep +
MutatorContext family passes (10+ test methods covering canonical
8.1.1-from-rel-810 case, idempotency, multi-row placeholder drop,
single-rel-branch base case, comment preservation,
next-owner-prefers-newer-rel-branch-over-master,
missing-rel-branch-throws, historical-secondary-row-not-re-pinned,
inline-comments-preserved on scalars, legacy rel-704 idempotency)
- [x] Dry-run against the live `.github/release-targets.yml` for
8.1.1-from-rel-810 produces the expected diff (rel-810 pinned to v8_1_1
with next->latest, rel-800 drops latest, master acquires next,
placeholder row + its comments dropped)
- [x] Second-run idempotency confirmed via CLI
- [x] Workflow YAML parses (yaml.safe_load) and actionlint clean
- [ ] End-to-end conductor exercise — requires push to a rel-* branch;
covered by Phase B's cherry-pick to rel-810

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bradymiller added a commit that referenced this pull request Jul 1, 2026
## Context for anyone catching up

This PR builds the patch-prep automation arm of the release-mechanism
migration — sibling to the branch-cut automation in #12696. The same
reference docs lay out the plan:

- **Planning doc — workstream 6 detail**:
https://github.com/bradymiller/openemr/blob/release-mechanism-migration-doc/docs/release-mechanism-migration-from-devops.md
(search for *Workstream 6 — patch-prep automation*)
- **Gaps doc — G12** (plus G4 + G5 for the workstream 2 mutator
framework this builds on):
https://github.com/bradymiller/openemr/blob/release-mechanism-gaps-doc/docs/release-mechanism-gaps.md

> **Naming note (2026-06-30):** Originally labelled workstream 3 Phase
B; renamed to workstream 6 in the planning/gaps doc updates (PRs #12598
and #12599) to avoid collision with workstream 3's actual Phase B — the
cherry-pick of #12662's conductor extension to rel-810 for the 8.1.1
ship scenario. The COMMIT messages on this branch retain the original
"workstream 3 phase B" framing; rewriting them would require a
force-push and isn't worth it for a naming refinement. The PR title +
body here reflect the new naming.

**Based on #12696** (workstream 2 — branch-cut automation), which is in
turn based on #12662 (workstream 3 phase A — release-finalize partner
PR). HEAD of this PR's branch sits on top of #12696's HEAD
(`b9cf8d3714`). The diff against `master` therefore includes all three
PRs' commits; once #12662 and #12696 land in order, this PR rebases
cleanly and the diff shrinks to just this workstream's commits. No
overlap with #12696's mutator code — this PR's changes to
`DockerUpgradeScaffoldMutator` and `SqlUpgradeSkeletonMutator` are
strictly additive (they consult `MutatorContext::$fromVersion` when set,
fall through to existing behavior otherwise).

## Summary

When a maintainer pushes a `$v_patch` increment to `version.php` on a
`rel-*` branch (e.g., rel-810 going `8.1.0` → `8.1.1-dev`), GitHub
Actions today does nothing. This PR makes that push event fire a
workflow that opens **two coordinated ready-for-review PRs** (not drafts
— patch-prep PRs should land fast, paving the way for the new dev
cycle):

- **rel-side PR** (`patch-prep/rel-NNN0` → base `rel-NNN0`): docker
upgrade scaffold + new SQL patch upgrade skeleton.
- **master-side PR** (`patch-prep/rel-NNN0-master` → base `master`):
same docker upgrade scaffold + same new SQL patch upgrade skeleton +
master bridge file rename + `release-targets.yml` row insert +
placeholder drop.

Maintainer reviews both, marks Ready (already there — not draft), merges
rel-side first, then master-side.

## Trigger mechanics + gate logic

`on: push` to `branches: rel-*` filtered to `paths: version.php`. From
that, the resolve step:

1. **New-branch creation** (`before` SHA all zeros) → skip cleanly.
Branch-cut (#12696) owns that lifecycle event.
2. Fetch before-state + after-state `version.php` via the GitHub API;
parse `$v_major`, `$v_minor`, `$v_patch`, `$v_tag` with regex.
3. **Gate 1**: `$v_patch` must have strictly increased (`after_patch >
before_patch`). Cosmetic edits to other parts of `version.php` skip.
4. **Gate 2**: after-state `$v_tag` must be `-dev`. release-prep's
mid-flight strip of `-dev` to ship a release also bumps `$v_patch` in
some flows; that's not our event.
5. If both gates pass, derive `target_version = MAJOR.MINOR.after_patch`
and `prev_version = MAJOR.MINOR.before_patch`.

`workflow_dispatch` provides an escape hatch — maintainer supplies
`rel-branch`, `target-version`, `prev-version` explicitly for recovery
from a workflow miss.

## Mutator inventory

**Reused from #12696 without code modification (just additive context
extension):**

| Mutator | Side | Behavior |
|---------|------|----------|
| `DockerUpgradeScaffoldMutator` | both | Bumps 3 docker-version files
(N → N+1), creates `fsupgrade-(N+1).sh` stub, extends Dockerfile COPY +
chmod blocks. Cross-branch sync requirement per the `docker upgrade
actions mandatory per release` rule. |
| `SqlUpgradeSkeletonMutator` | both | Creates
`sql/<from>-to-<to>_upgrade.sql` with the comment-meta-language header.
|

**New in this PR:**

| Mutator | Side | Behavior |
|---------|------|----------|
| `MasterSqlPatchBridgeMutator` | master | Renames the long-lived bridge
file `sql/<X_Y_(P-1)>-to-<X_(Y+1)_0>_upgrade.sql` →
`sql/<X_Y_P>-to-<X_(Y+1)_0>_upgrade.sql`. Contents preserved (they
accumulate dev-cycle SQL); only the "from" anchor in the filename
advances. |
| `PatchPrepReleaseTargetsMutator` | master | Inserts a fresh `- branch:
rel-NNN0` row with `docker_tags: <target>,next`; drops any rows for the
target branch flagged `unreleased: true`. Other branches' rows
untouched. Surgical line-based edits to preserve comments; Symfony YAML
parser used as a structural sanity check on the result. |

**`MutatorContext` extension** — adds optional `?string $fromVersion`
field with `MAJOR.MINOR.PATCH` validation. The patch-prep conductor
supplies it because:
- Rel side: `version.php` has already been bumped past the value
`SqlUpgradeSkeletonMutator` needs to anchor at (post-bump `$v_patch` is
the *target*, not the *from*).
- Master side: `version.php` is for the next-minor line entirely (e.g.,
`8.2.0-dev`) and bears no relationship to the rel-branch patch.

Both consuming mutators fall through to existing behavior when
`fromVersion` is null, so the branch-cut path is unaffected.

## Mutator order (master side)

```
1. DockerUpgradeScaffoldMutator       (cross-branch sync)
2. SqlUpgradeSkeletonMutator          (creates new patch's <from>-to-<to>.sql)
3. MasterSqlPatchBridgeMutator        (renames the long-lived bridge file)
4. PatchPrepReleaseTargetsMutator     (release-targets.yml row insert + placeholder drop)
```

The skeleton mutator runs **before** the bridge mutator deliberately.
Both touch disjoint files (skeleton writes `<X_Y_P-1>-to-<X_Y_P>.sql`;
bridge renames `<X_Y_P-1>-to-<X_(Y+1)_0>.sql` →
`<X_Y_P>-to-<X_(Y+1)_0>.sql`), but in a partial-run scenario the
skeleton-first ordering means the new patch file is present before the
long-lived bridge gets disturbed. Also matches the rel-side ordering.

## Lifecycle

1. Maintainer bumps `$v_patch` on a rel branch (`rel-810`:
`$v_patch='0'` → `$v_patch='1'`, `$v_tag='-dev'`) and pushes.
2. `patch-prep-automation.yml` fires, resolves target-version = 8.1.1 +
prev-version = 8.1.0.
3. Workflow checks out the rel branch, runs `php bin/console
openemr:patch-prep --side=rel`, opens `patch-prep/rel-810`
ready-for-review PR.
4. Workflow checks out master, runs `php bin/console openemr:patch-prep
--side=master`, opens `patch-prep/rel-810-master` ready-for-review PR.
5. Maintainer reviews both, merges rel-side first, then master-side.

## Test plan

- [x] `openemr-cmd worktree exec ws-patch-prep-automation pit` — full
isolated suite passes (3572 tests)
- [x] `openemr-cmd worktree exec ws-patch-prep-automation pst` — PHPStan
level 10 clean
- [x] `openemr-cmd worktree exec ws-patch-prep-automation pr` — phpcs
clean on all new files (only finding is the pre-existing
`sites/default/sqlconf.php` dev-stack auto-config write, same as #12696)
- [x] Local dry-run on this worktree, both sides, producing the diffs in
the *Sample diffs* section below
- [x] Idempotency verified on both new mutators
(`MasterSqlPatchBridgeMutator`, `PatchPrepReleaseTargetsMutator`): each
has dedicated `testIdempotentOnRerun` /
`testIdempotentWhenNewExistsAndOldGone` tests
- [x] YAML output of `PatchPrepReleaseTargetsMutator` parsed via Symfony
YAML as structural sanity check
- [ ] CI green on this PR's checks
- [ ] (Future) End-to-end exercise at the next real patch-prep on a live
rel branch

## Note about `sites/default/docker-version` and the docker volume

Same as #12696: the easy-dev docker stack used to capture the dry-run
diffs below mounts `sites/default/` as a docker volume rather than a
host bind, so `DockerUpgradeScaffoldMutator`'s write to
`sites/default/docker-version` doesn't propagate back to the host. The
mutator DOES write it (verified by inspecting the volume contents inside
the container) — it just doesn't surface on the host. The actual CI
workflow run won't have this quirk; all 3 docker-version files
(top-level `docker-version`, `docker/release/upgrade/docker-version`,
`sites/default/docker-version`) will appear in the real patch-prep PR's
diff.

## Sample diffs — what the patch-prep PRs will look like

Captured via `php bin/console openemr:patch-prep --target-version=8.1.2
--rel-branch=rel-810 --prev-version=8.1.1 --side=<rel|master>` against
the worktree at this commit. Scenario: rel-810 has just shipped 8.1.1;
maintainer bumps to `8.1.2-dev`.

<details>
<summary>Master-side patch-prep PR diff (9 files; SQL skeleton header
truncated)</summary>

```diff
diff --git a/.github/release-targets.yml b/.github/release-targets.yml
index e070a34..61b552efe9 100644
--- a/.github/release-targets.yml
+++ b/.github/release-targets.yml
@@ -103,19 +103,12 @@
   openemr_version_ref: master
 
 - branch: rel-810
-  docker_tags: 8.1.1,next
+  docker_tags: 8.1.2,next
   openemr_version_ref: rel-810
 
-# Second rel-810 row -- placeholder for the multi-row dev pattern (see
-# `unreleased` field doc above). Wires up the second-row mechanism
-# without actually publishing 8.1.0 from this configuration. When a real
-# dev cycle on rel-810 starts (e.g., 8.1.2-dev with 8.1.1 already
-# shipped), this row gets repurposed: docker_tags -> 8.1.1,
-# openemr_version_ref -> v8_1_1, `unreleased` flag dropped.
 - branch: rel-810
-  docker_tags: 8.1.0
-  openemr_version_ref: v8_1_0
-  unreleased: true
+  docker_tags: 8.1.1,next
+  openemr_version_ref: rel-810
 
 - branch: rel-800
   # 8.0.0 is the floating "current latest 8.0.0.x" pointer (always advances

diff --git a/docker-version b/docker-version
index b4de394..48082f7 100644
--- a/docker-version
+++ b/docker-version
@@ -1 +1 @@
-11
+12

diff --git a/docker/release/upgrade/docker-version b/docker/release/upgrade/docker-version
index b4de394..48082f7 100644
--- a/docker/release/upgrade/docker-version
+++ b/docker/release/upgrade/docker-version
@@ -1 +1 @@
-11
+12

diff --git a/docker/release/Dockerfile b/docker/release/Dockerfile
index 7ba67a1..5b601c1 100644
--- a/docker/release/Dockerfile
+++ b/docker/release/Dockerfile
@@ -289,6 +289,7 @@ COPY upgrade/docker-version \
      upgrade/fsupgrade-9.sh \
      upgrade/fsupgrade-10.sh \
      upgrade/fsupgrade-11.sh \
+     upgrade/fsupgrade-12.sh \
      /root/
 
 # Set upgrade scripts as executable (read and execute for owner only)
@@ -303,7 +304,8 @@ RUN chmod 500 \
     /root/fsupgrade-8.sh \
     /root/fsupgrade-9.sh \
     /root/fsupgrade-10.sh \
-    /root/fsupgrade-11.sh
+    /root/fsupgrade-11.sh \
+    /root/fsupgrade-12.sh
```

Bridge rename (the `sql/8_1_1-to-8_2_0_upgrade.sql` file's contents are
preserved exactly; only the filename changes):

```diff
-sql/8_1_1-to-8_2_0_upgrade.sql   [deleted]
+sql/8_1_2-to-8_2_0_upgrade.sql   [created, byte-identical to deleted]
```

New files:

```
sql/8_1_1-to-8_1_2_upgrade.sql            (new — header-only SQL upgrade skeleton)
docker/release/upgrade/fsupgrade-12.sh    (new — see contents in rel-side block below)
```

</details>

<details>
<summary>Rel-side patch-prep PR diff (5 files; byte-identical docker
scaffold to master-side, plus skeleton)</summary>

```diff
diff --git a/docker-version b/docker-version
index b4de394..48082f7 100644
--- a/docker-version
+++ b/docker-version
@@ -1 +1 @@
-11
+12

diff --git a/docker/release/upgrade/docker-version b/docker/release/upgrade/docker-version
index b4de394..48082f7 100644
--- a/docker/release/upgrade/docker-version
+++ b/docker/release/upgrade/docker-version
@@ -1 +1 @@
-11
+12

diff --git a/docker/release/Dockerfile b/docker/release/Dockerfile
index 7ba67a1..5b601c1 100644
--- a/docker/release/Dockerfile
+++ b/docker/release/Dockerfile
@@ -289,6 +289,7 @@ COPY upgrade/docker-version \
      upgrade/fsupgrade-9.sh \
      upgrade/fsupgrade-10.sh \
      upgrade/fsupgrade-11.sh \
+     upgrade/fsupgrade-12.sh \
      /root/
 
@@ -303,7 +304,8 @@ RUN chmod 500 \
     /root/fsupgrade-8.sh \
     /root/fsupgrade-9.sh \
     /root/fsupgrade-10.sh \
-    /root/fsupgrade-11.sh
+    /root/fsupgrade-11.sh \
+    /root/fsupgrade-12.sh
```

New `docker/release/upgrade/fsupgrade-12.sh`:

```bash
#!/bin/bash
# Upgrade number 12 for OpenEMR docker
#  From prior version 8.1.1 (needed for the sql upgrade script).
priorOpenemrVersion="8.1.1"
echo "Start: Upgrade to docker-version 12"
# TODO: fill in upgrade logic per-release; see prior fsupgrade-*.sh for examples
echo "Completed: Upgrade to docker-version 12"
```

New `sql/8_1_1-to-8_1_2_upgrade.sql` (header-only — first 10 of ~190
lines shown):

```sql
--
--  Comment Meta Language Constructs:
--
--  #IfNotTable
--    argument: table_name
--    behavior: if the table_name does not exist,  the block will be executed

--  #IfTable
--    argument: table_name
--    behavior: if the table_name does exist, the block will be executed
... [comment-meta-language header continues; no upgrade SQL — per-release work fills the body]
```

</details>

## Design decisions worth flagging

- **Sibling command, not `--scope=patch-prep` extension.** Matches the
precedent set by #12696 (which itself follows #12662's `--scope=master`
framing for release-finalize). Each lifecycle event gets its own command
with its own mutator list.
- **`MasterSqlPatchBridgeMutator` reports BOTH old and new paths** in
its `MutatorResult::changedFiles` (since rename = delete + create). The
conductor's output shows both for visibility.
- **`PatchPrepReleaseTargetsMutator` is scoped to the target rel
branch.** Unlike `BranchCutReleaseTargetsMutator` (#12696) which drops
all `unreleased: true` rows uniformly, patch-prep only drops
placeholders for the branch being patched. Other rel branches'
placeholders are not our concern.
- **`workflow_dispatch` requires explicit `prev-version`** rather than
computing it from current version.php. Recovery scenarios may want a
different baseline than auto-detect would pick.

## Notes

- DO NOT enable auto-merge.
- BASED ON #12696 — will need rebase after that lands. Should be clean:
this PR's only overlap with #12696 is on `MutatorContext.php` (additive
`fromVersion` field), `DockerUpgradeScaffoldMutator.php` (additive
`fromVersion` consumption in one helper), and
`SqlUpgradeSkeletonMutator.php` (additive `fromVersion` consumption in
one line) — all strictly additive against #12696's versions.
- For the `peter-evans/create-pull-request@v8` action, verified
beforehand that the workflow's target branches (`patch-prep/rel-810`,
`patch-prep/rel-810-master`) do not exist on the remote (clean state for
first run).



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added patch-prep automation for release branches, including new pull
request templates and a workflow that opens coordinated rel-side and
master-side PRs.
* Added a new command to generate patch-prep changes and report the
files updated.

* **Bug Fixes**
* Improved version handling so patch-prep can use the exact previous
patch version when creating upgrade scaffolding.
* Added safer handling for master SQL bridge file renames and
release-target list updates.

* **Tests**
* Added coverage for the new patch-prep flow, version validation, and
file-update behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Reflect the 2026-07-01 landings to openemr/openemr master: G4 + G5
consumed by workstream 2 branch-cut automation (PR openemr#12696,
`6513b487b6`) via new `openemr:branch-cut` command + workflow + 4
new mutators; G11 Phase A shipped as release-finalize partner PR (PR
openemr#12662, `abb1e2d940`); G12 shipped as workstream 6 patch-prep
automation (PR openemr#12697, `c1af6370a0`). Note in-flight rabbit-fix
follow-up PR openemr#12709 addressing 3 Major items missed at openemr#12696 merge.
Wire G3's forward reference to `SqlUpgradeSkeletonMutator` to its
now-shipped consumers in workstreams 2 + 6. Resolve conditional
sequencing subsections now that 8.1.1 is being pursued and all three
workstreams landed to master before the 8.1.1 ship.

Assisted-by: Claude Code
bradymiller added a commit that referenced this pull request Jul 1, 2026
…12712)

Prep for the imminent rel-820 cut. 8.1.x is being skipped entirely (no
8.1.1 ship), so both rel-810 rows should be gone by the time
\`branch-cut-automation.yml\` fires on the \`create\` event.

## Changes

- **Add \`unreleased: true\` to the rel-810 \`8.1.1,next\` row.** Pauses
daily publishing from rel-810 across the cut window.
\`BranchCutReleaseTargetsMutator.dropAllUnreleasedRows()\` (from #12696)
will then uniformly drop this row alongside the existing \`8.1.0\`
placeholder — leaving no rel-810 rows post-cut, matching the skip-line
scenario documented in \`docs/release-mechanism-gaps.md\` G5 refinement.
- **Move the \`next\` tag from rel-810 to master.** Interim posture so
master owns \`next\` temporarily during the cut. At the \`create\`
event, \`BranchCutReleaseTargetsMutator.bumpMasterRow()\` drops master's
\`next\` and bumps minor to \`8.3.0,dev\`, and the newly-inserted
rel-820 row picks up \`8.2.0,next\`.

## Post-cut end state (once branch-cut workflow runs against rel-820)

\`\`\`yaml
- branch: master
  docker_tags: 8.3.0,dev
  openemr_version_ref: master

- branch: rel-820
  docker_tags: 8.2.0,next
  openemr_version_ref: rel-820

- branch: rel-800
  docker_tags: 8.0.0,8.0.0.3,latest
  openemr_version_ref: v8_0_0_3

- branch: rel-704
  docker_tags: 7.0.4
  openemr_version_ref: v7_0_4
\`\`\`

## Test plan

- [ ] \`docker-validate-release-targets.yml\` CI check passes
(docker_tag ↔ version.php alignment: master's \`8.2.0,dev,next\` should
still align with master's current \`\$v_major.\$v_minor = 8.2\`;
rel-810's \`8.1.1\` is on an unreleased row so should be exempt from the
guard).
- [ ] After merge + rel-820 cut, verify the branch-cut PRs open with the
expected end state.

## References

Skip-line cut pattern: \`docs/release-mechanism-gaps.md\` G5 "Refinement
(2026-06-30) — Skip-line cut scenario" (already in master via #12599).
…rcise

8.1.x is being skipped entirely; no 8.1.1 ship. rel-820 cut becomes the first end-to-end exercise of branch-cut automation. Workstream 3 Phase B (cherry-pick to rel-810) is now unnecessary. Pre-cut posture in openemr#12712.

Assisted-by: Claude Code
…20 cut, 2026-07-02)

Assisted-by: Claude Code
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant