From 3be9a3aedb79db111aeb6b0203cac4ba6da58275 Mon Sep 17 00:00:00 2001 From: Brady Miller Date: Thu, 25 Jun 2026 08:02:53 +0000 Subject: [PATCH 1/4] refactor(release): collapse to 2-PR shape + delete dead rotation slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atomic deletion of the rotation slice and collapse of ship-release.yml from 3 PRs (Infra + Conductor + Docs) to 2 PRs (Conductor + Docs). The docker-pipeline migration to openemr/openemr (#790, completed 2026-06-20) moved all of rotation's live targets out of this repo. versions.yml's 13 entries all point at paths that no longer exist (docker/openemr/**, utilities/container_benchmarking/**, two deleted workflows); SlotRotator's last run on 2026-06-10 left the release-rotation/auto PR (openemr-devops#760) frozen with patches that edit files which now return 404 on master. Subsequent release-rotation.yml runs no-op because the diff against the existing branch is empty. This makes shipping today's 8.1.1 release impossible through ship-release.yml: preflight blocks on PR #760's unmergeable state, or merging it would silently resurrect the deleted docker/openemr/* tree. Collapsing to 2-PR removes the Infra slot from PullRequestTarget + ShipReleaseOrchestrator entirely; PR #760 becomes a dangling OPEN PR (close manually as cosmetic cleanup after this PR merges). Coordinated companion: openemr/openemr#12631 updates the cross-repo docs (RELEASE_PROCESS.md + release-automation-plan.md) to the 2-PR shape. Order: land #12631 first (docs match imminent code), then this PR (code catches up). DELETED (16 files, ~2625 LOC): - tools/release/versions.yml (registry pointing at deleted paths) - tools/release/src/SlotRotator.php, SlotAssignmentParser.php, SlotRotationResult.php, RotationPrPublisher.php, VersionsRegistryLinter.php, LintIssue.php - tools/release/bin/rotate.php, lint-versions.php, open-rotation-pr.php, derive-slots-from-dispatch.php - tools/release/tests/SlotRotatorTest.php, SlotAssignmentParserTest.php, VersionsRegistryLinterTest.php, RotationPrPublisherTest.php - .github/workflows/release-rotation.yml - docs/release-automation-plan.md (devops-side rotation slice pre-implementation design doc — misleading once the rotation code is gone) MODIFIED (13 files): - tools/release/src/PullRequestTarget.php: forRelease() drops the Infra row; Conductor mergeOrder 2->1, Docs mergeOrder 3->2; docblock updated - tools/release/src/RoleLabel.php: drop Infra enum case; docblock three->two - tools/release/src/ShipReleaseOrchestrator.php: three docblocks updated (class header, ship() targets list, sortByMergeOrder comment); no runtime code changes (orchestrator already used only RoleLabel::Conductor and RoleLabel::Docs in its logic) - tools/release/tests/PullRequestTargetTest.php: test renamed + rewritten for 2-target shape - tools/release/tests/ShipReleaseOrchestratorTest.php: full rewrite - dropped INFRA_REPO/INFRA_BRANCH constants, removed all infra fixture setup, renumbered step assertions, renamed test methods, dropped testInfraAlreadyMergedSkipsThenContinues (no 2-PR analog; SKIPPED_ALREADY_MERGED coverage preserved by testConductorAlreadyMergedRefetchesDocsBeforeMerging) - tools/release/tests/ShipReleaseSummaryRendererTest.php: dropped Infra fixture entries; first two tests now exercise Conductor rows; helper updated - tools/release/Taskfile.yml: dropped release:rotate, release:lint-versions, release:open-rotation-pr, release:derive-slots-from-dispatch, release:push-rotation-branch tasks (~80 lines); release:ship desc updated three->two - tools/release/bin/ship-release.php: header docblock + Symfony setDescription updated three->two - tools/release/README.md: dropped versions.yml row from the directory-tree diagram - tools/release/src/AppPermissionProbe.php: comment updated to drop the rotation-specific motivation (workflows:write probe still needed for other release-mechanism workflows) - .github/workflows/release-permissions-check.yml: header comment updated to drop the 'only what rotation needs' framing - .github/workflows/ship-release.yml: header comment updated for 2-PR shape NOT TOUCHED (intentional): - The cross-repo docs in openemr/openemr (covered by #12631) - Anything outside the release-mechanism + permissions-check surfaces (kubernetes, packages, raspberrypi, etc.) Tests not run locally (devops repo has no docker dev-stack); CI will exercise the test rewrites. Workstream 1 PR 1a in the release-mechanism migration plan. Assisted-by: Claude Code --- .../workflows/release-permissions-check.yml | 5 +- .github/workflows/release-rotation.yml | 141 ------ .github/workflows/ship-release.yml | 6 +- docs/release-automation-plan.md | 209 --------- tools/release/README.md | 3 +- tools/release/Taskfile.yml | 69 +-- .../bin/derive-slots-from-dispatch.php | 94 ---- tools/release/bin/lint-versions.php | 79 ---- tools/release/bin/open-rotation-pr.php | 62 --- tools/release/bin/rotate.php | 94 ---- tools/release/bin/ship-release.php | 6 +- tools/release/src/AppPermissionProbe.php | 7 +- tools/release/src/LintIssue.php | 29 -- tools/release/src/PullRequestTarget.php | 9 +- tools/release/src/RoleLabel.php | 3 +- tools/release/src/RotationPrPublisher.php | 120 ----- tools/release/src/ShipReleaseOrchestrator.php | 6 +- tools/release/src/SlotAssignmentParser.php | 100 ----- tools/release/src/SlotRotationResult.php | 35 -- tools/release/src/SlotRotator.php | 265 ----------- tools/release/src/VersionsRegistryLinter.php | 180 -------- tools/release/tests/PullRequestTargetTest.php | 26 +- .../release/tests/RotationPrPublisherTest.php | 56 --- .../tests/ShipReleaseOrchestratorTest.php | 133 ++---- .../tests/ShipReleaseSummaryRendererTest.php | 13 +- .../tests/SlotAssignmentParserTest.php | 158 ------- tools/release/tests/SlotRotatorTest.php | 417 ------------------ .../tests/VersionsRegistryLinterTest.php | 226 ---------- tools/release/versions.yml | 227 ---------- 29 files changed, 75 insertions(+), 2703 deletions(-) delete mode 100644 .github/workflows/release-rotation.yml delete mode 100644 docs/release-automation-plan.md delete mode 100644 tools/release/bin/derive-slots-from-dispatch.php delete mode 100644 tools/release/bin/lint-versions.php delete mode 100644 tools/release/bin/open-rotation-pr.php delete mode 100644 tools/release/bin/rotate.php delete mode 100644 tools/release/src/LintIssue.php delete mode 100644 tools/release/src/RotationPrPublisher.php delete mode 100644 tools/release/src/SlotAssignmentParser.php delete mode 100644 tools/release/src/SlotRotationResult.php delete mode 100644 tools/release/src/SlotRotator.php delete mode 100644 tools/release/src/VersionsRegistryLinter.php delete mode 100644 tools/release/tests/RotationPrPublisherTest.php delete mode 100644 tools/release/tests/SlotAssignmentParserTest.php delete mode 100644 tools/release/tests/SlotRotatorTest.php delete mode 100644 tools/release/tests/VersionsRegistryLinterTest.php delete mode 100644 tools/release/versions.yml diff --git a/.github/workflows/release-permissions-check.yml b/.github/workflows/release-permissions-check.yml index 9f9b164b..05d2f9da 100644 --- a/.github/workflows/release-permissions-check.yml +++ b/.github/workflows/release-permissions-check.yml @@ -2,8 +2,9 @@ name: Release Permissions Check # Manual probe of the release App's installed permissions on this repo. # Mints an App token from the RELEASE_APP_CLIENT_ID org variable + -# RELEASE_APP_PRIVATE_KEY org secret and -# exercises only what tools/release/ rotation needs (per docs/release-automation-plan.md). +# RELEASE_APP_PRIVATE_KEY org secret and exercises what the +# release-mechanism workflows in tools/release/ need (build-release, +# release-announcements, ship-release, dispatch fan-out). # Run after installing the App and after secrets rotations. on: diff --git a/.github/workflows/release-rotation.yml b/.github/workflows/release-rotation.yml deleted file mode 100644 index 20d7b65a..00000000 --- a/.github/workflows/release-rotation.yml +++ /dev/null @@ -1,141 +0,0 @@ -name: Release Rotation - -# Long-lived rotation PR for the 3-slot model (current/next/dev). Triggered by -# repository_dispatch from openemr/openemr (per dispatch.schema.json) or -# manually via workflow_dispatch. Force-pushes a regenerated diff to -# release-rotation/auto and opens/updates the draft PR. Won't fire E2E until -# the conductor side lands; until then this is exercised via workflow_dispatch. - -on: - repository_dispatch: - types: [openemr-rel-cut, openemr-rel-update, openemr-tag] - workflow_dispatch: - inputs: - current: - description: 'New value for current slot (e.g. 8.1)' - required: false - type: string - next: - description: 'New value for next slot (e.g. 8.2)' - required: false - type: string - dev: - description: 'New value for dev slot (e.g. 8.2 or edge)' - required: false - type: string - -permissions: {} - -concurrency: - group: release-rotation - cancel-in-progress: false - -jobs: - rotate: - name: Apply rotation and update draft PR - runs-on: ubuntu-24.04 - env: - ROTATION_BRANCH: release-rotation/auto - EVENT_NAME: ${{ github.event_name }} - DISPATCH_TYPE: ${{ github.event.action }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - steps: - - name: Mint release App token - id: app-token - uses: actions/create-github-app-token@v3 - with: - client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - - - name: Checkout - uses: actions/checkout@v7 - with: - token: ${{ steps.app-token.outputs.token }} - fetch-depth: 0 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.5' - - - name: Install Task - uses: arduino/setup-task@v2 - - - name: Derive slot assignments - id: slots - env: - CLIENT_PAYLOAD: ${{ toJSON(github.event.client_payload) }} - INPUT_CURRENT: ${{ inputs.current }} - INPUT_NEXT: ${{ inputs.next }} - INPUT_DEV: ${{ inputs.dev }} - working-directory: tools/release - run: | - set -euo pipefail - if [[ ${EVENT_NAME} == 'workflow_dispatch' ]]; then - { - printf 'current=%s\n' "${INPUT_CURRENT}" - printf 'next=%s\n' "${INPUT_NEXT}" - printf 'dev=%s\n' "${INPUT_DEV}" - } >> "${GITHUB_OUTPUT}" - else - printf '%s' "${CLIENT_PAYLOAD}" \ - | task release:derive-slots-from-dispatch EVENT_TYPE="${DISPATCH_TYPE}" PAYLOAD_FILE='-' \ - >> "${GITHUB_OUTPUT}" - fi - - - name: Apply rotation - env: - CURRENT: ${{ steps.slots.outputs.current }} - NEXT: ${{ steps.slots.outputs.next }} - DEV: ${{ steps.slots.outputs.dev }} - working-directory: tools/release - run: | - set -euo pipefail - if [[ -z ${CURRENT} && -z ${NEXT} && -z ${DEV} ]]; then - printf '::warning::no slot assignments derived; nothing to rotate\n' - exit 0 - fi - task release:rotate CURRENT="${CURRENT}" NEXT="${NEXT}" DEV="${DEV}" - - - name: Diff and decide - id: diff - run: | - set -euo pipefail - if git diff --quiet; then - printf 'has-changes=false\n' >> "${GITHUB_OUTPUT}" - printf '::notice::rotation is a no-op (registry already at target)\n' - else - printf 'has-changes=true\n' >> "${GITHUB_OUTPUT}" - git diff --stat - fi - - - name: Force-push rotation branch - if: steps.diff.outputs.has-changes == 'true' - working-directory: tools/release - run: | - set -euo pipefail - msg=$(printf 'rotation: %s (run %s)' "${DISPATCH_TYPE:-workflow_dispatch}" "${RUN_URL}") - task release:push-rotation-branch \ - ROTATION_BRANCH="${ROTATION_BRANCH}" \ - COMMIT_MESSAGE="${msg}" - - - name: Open or update rotation PR - if: steps.diff.outputs.has-changes == 'true' - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - GH_REPO: ${{ github.repository }} - CURRENT: ${{ steps.slots.outputs.current }} - NEXT: ${{ steps.slots.outputs.next }} - DEV: ${{ steps.slots.outputs.dev }} - working-directory: tools/release - run: | - set -euo pipefail - default_branch=$(gh api "/repos/${GITHUB_REPOSITORY}" --jq '.default_branch') - task release:open-rotation-pr \ - BRANCH="${ROTATION_BRANCH}" \ - BASE="${default_branch}" \ - TRIGGER="${DISPATCH_TYPE:-workflow_dispatch}" \ - RUN_URL="${RUN_URL}" \ - CURRENT="${CURRENT}" \ - NEXT="${NEXT}" \ - DEV="${DEV}" diff --git a/.github/workflows/ship-release.yml b/.github/workflows/ship-release.yml index 525eb91c..4e84aed1 100644 --- a/.github/workflows/ship-release.yml +++ b/.github/workflows/ship-release.yml @@ -1,10 +1,10 @@ name: Ship Release -# Manually-triggered orchestration for the three-PR release flow (#705): -# merges infra → conductor → docs in strict order, skipping any already merged +# Manually-triggered orchestration for the two-PR release flow (#705): +# merges conductor → docs in strict order, skipping any already merged # (so the same trigger handles partial-merge recovery), and refusing to merge # anything if any unmerged PR is not ready. Mints one App token with PR-write -# on all three repos and posts a release/ship-approved commit status on each +# on both repos and posts a release/ship-approved commit status on each # PR head before merging it (so branch protection can require it later). on: diff --git a/docs/release-automation-plan.md b/docs/release-automation-plan.md deleted file mode 100644 index 6091dac5..00000000 --- a/docs/release-automation-plan.md +++ /dev/null @@ -1,209 +0,0 @@ -# Release automation — `openemr-devops` slice - -Tracks: openemr/openemr-devops#664 (refines #662, overlaps with #638) - -This repo owns the **infra / test-matrix PR** in the three-PR release flow. The -`openemr/openemr` conductor is upstream; the `website-openemr` docs PR is a -sibling consumer. This document is the plan for what lands here. - -## Role in the flow - -``` -openemr/openemr release-prep PR ── merge → tag v8_1_0 - │ │ - └── (push to rel-*) ───────────┼──→ website-openemr docs PR - │ - └──→ openemr-devops infra PR ← this repo -``` - -Triggered by `repository_dispatch` from `openemr/openemr` on: - -- `rel-*` branch cut / push → roll `next` forward, keep `current` pinned -- `v*_*_*` tag → promote `next` → `current`, drop the prior `current` - -No cron, no human handoff between workflows. - -## What the workflow rotates - -Per #638, the matrix collapses to three rotating slots: - -| Slot | Meaning | Example after rel-8.2 cut | -| --------- | -------------------------------------- | ------------------------- | -| `current` | most recent tagged release | 8.1 | -| `next` | release candidate (active `rel-*`) | 8.2 | -| `dev` | head of master | edge | - -Today the matrix is hardcoded across files like: - -- the three rotating build workflows — now consolidated into one - `build-openemr.yml` whose matrix is the stable slot names (see step #5) -- `.github/workflows/test-flex-322.yml`, `test-flex-323.yml`, `test-flex-edge.yml` -- `.github/workflows/build-edge.yml`, `build-flex-core.yml`, `build-release.yml` -- `docker/**` Dockerfiles that pin OpenEMR version -- `kubernetes/**` manifests with image tags -- `raspberrypi/**` build configs -- `packages/**` package version refs - -The infra PR is a long-lived PR against `master` that the workflow -force-updates on every dispatch. Merging it is the "infra is ready for the new -`current`/`next`" decision. - -## Components to build - -In dependency order: - -Build on the existing `tools/release/` foundation (composer + Symfony-style -`bin/*.php` console scripts + `src/` classes + `Taskfile.yml`). Notably, -`src/VersionBumper.php` and `bin/version-bump.php` already exist and likely -host most of the rewrite logic. - -1. **`tools/release/bin/rotate.php` console script** (and supporting - `src/SlotRotator.php` extending or composing `VersionBumper`). - - Inputs: target slot assignments (e.g. `--current=8.1 --next=8.2 - --dev=edge`). - - Reads a single source-of-truth config (`tools/release/versions.yml`) - listing every file + jsonpath/regex that holds a version reference. - - Rewrites those files in place, idempotently. `--dry-run` prints the diff. - - Lints with `php -l`, PHPStan (existing `phpstan.neon`), and PHPCS - (existing `phpcs.xml`). - - Exposed via `task release:rotate` in `tools/release/Taskfile.yml`, - mirroring the existing `task release:changelog` pattern. - -2. **`tools/release/versions.yml` registry.** - - One entry per file holding a version. Built by sweeping the repo once and - classifying each pin as `current` / `next` / `dev` (or explicit - `excludes:`). - - Reviewed by hand — this is the part that has to be right. - -3. **Workflow `.github/workflows/release-rotation.yml`.** - - Triggers: `repository_dispatch` (`types: [openemr-rel-cut, openemr-tag]`) - and `workflow_dispatch` (manual override). - - Steps: checkout → run rotation script → if diff, force-push to - `release-rotation/auto` branch and open/update a draft PR. - - PR body summarizes which slot moved and links the upstream event. - -4. **PAT / app credential** with `contents:write` and `pull-requests:write` on - this repo, accepted via `repository_dispatch` from openemr/openemr. - -5. **Workflow consolidation (#638 follow-on).** - - Done: the three rotating build workflows collapsed into a single - `build-openemr.yml`. Its matrix is the **stable slot names** - `[current, next, dev]` × platform — never rotated. Each job resolves the - slot's real version at runtime from the slot symlink - `docker/openemr/{current,next,dev}` (e.g. `current → 8.0.0`) and builds - from the resolved version dir. Because the workflow holds no version - strings, rotation never rewrites it; instead `SlotRotator` re-points the - symlink when a slot's `docker_dir` changes. The merge job derives the - published `:` from `version.php` (fetched at the slot Dockerfile's - `OPENEMR_VERSION` ref), so `current` publishes its true `8.0.0.3` and - `dev` self-identifies as `8.1.1-dev`. Each slot publishes its floating tag - (`current → :latest`, `next → :next`, `dev → :dev`) plus `:` and - `:-`, and `current`/`next` (never `dev`) additionally publish - the bare-dir tag (`8.0.0` / `8.1.0`) when it differs from `:`. - - Remaining: same matrix-collapse for the `test-flex-*.yml` matrices. - -## Permissions self-check - -`.github/workflows/release-permissions-check.yml` (manual `workflow_dispatch`). -Mints an App token from the `RELEASE_APP_CLIENT_ID` org variable + -`RELEASE_APP_PRIVATE_KEY` org secret and -probes only what this repo's rotation workflow needs: - -- `GET /installation/repositories` — confirm this repo is in the install list. -- `GET /repos/openemr/openemr-devops` — confirm the App can read the repo. -- Create + delete a throwaway branch `release-permissions-check/` — - confirm `contents:write`. -- Commit an inert stub under `.github/workflows/` on that branch — confirm - `workflows:write`. Rotation still rewrites workflow files — - `.github/workflows/test-bats.yml` and `test-container-functionality.yml` - carry rotating `docker/openemr//**` path filters per - `tools/release/versions.yml` — so the App must be able to update workflow - files; a plain-dotfile probe doesn't exercise this permission and the gap - surfaced as a push rejection in release-rotation.yml (see openemr-devops#758). - (The consolidated `build-openemr.yml` is no longer rewritten — it resolves - versions via slot symlinks — but the test-path rewrites keep this needed.) -- Open + close a draft PR from that branch — confirm `pull-requests:write`. - -Fails loudly with the missing permission name. Run after installing the App; -re-run if secrets are rotated. Pure consumer here, so no cross-repo dispatch -to probe. - -## Out of scope here - -- Conductor workflow lives in `openemr/openemr`. -- Docs / Hugo / OpenAPI publishing lives in `website-openemr` (+ `-files`). -- Wiki migration is a docs-PR concern. -- Choice of release manager UX (which PR they merge first, etc.) is documented - in the conductor PR. - -## Open questions - -- Do we keep `build-edge.yml` separate or fold it into `build-openemr.yml`'s - `dev` slot? Naming consistency vs. churn in CI history. -- Should the rotation PR auto-merge on green CI, or always require a human? - Default: human-merge, since this gates the release. -- Where does `raspberrypi/` live in the rotation? Its release cadence has - historically lagged — may need its own slot or an opt-out flag. - -## Hypotheses (claims this slice rises or falls on) - -1. **The three-slot rotation covers every version pin in this repo.** Nothing - is per-minor-version forever; everything maps to `current` / `next` / `dev` - or has an explicit opt-out. -2. **The registry can be kept honest by lint.** A repo sweep can enumerate - every version-looking string and fail CI if any aren't listed in - `versions.yml`. -3. **Cross-repo `repository_dispatch` from openemr/openemr is reliable and - ordered enough** to be the only coupling — no polling, no shared state. -4. **Force-pushing the long-lived rotation PR is acceptable to reviewers.** - The diff is regenerated, not authored. - -## Assumptions - -- An app or PAT with `contents:write` + `pull-requests:write` on this repo - will be provisioned and accepted from openemr/openemr's dispatcher. -- Rotation always advances forward (no rollback flow); a botched rotation is - resolved by hand-editing the PR or merging a corrective dispatch. -- `rel-*` is the only release-branch naming pattern we need to recognize. -- raspberrypi/ and packages/ pins fit the same three-slot model (or accept an - explicit opt-out flag in the registry). - -## Testing - -### Independent / per-component (fast, no cross-repo) - -- **Rotator unit tests** (PHPUnit, alongside the existing tests in - `tools/release/`). Fixture `versions.yml` + sample workflow files; assert - each rewrite is correct and **idempotent** (run twice → no diff). Cover: - bumping `next`, promoting `next`→`current`, dropping a prior `current`, - no-op dispatch. -- **Registry-coverage lint.** Sweeps the repo for version-looking strings - (regex over `8\.\d+(\.\d+)?`, image tags, `docker-version` files) and fails - if any aren't enumerated in `versions.yml` (allowed via explicit - `excludes:` list). Catches drift when contributors add a new pinned file. -- **Workflow YAML validation.** `actionlint` + JSON-schema check of the - `repository_dispatch` payload this workflow accepts. - -### Single-repo integration - -- **`workflow_dispatch` synthetic run.** Fire the rotation workflow with a - hand-crafted payload (`{ current: "8.1", next: "8.2", dev: "edge" }`), - assert the rotation PR updates with the expected diff. -- **Re-dispatch idempotence.** Fire the same payload twice, assert the - resulting PR is byte-identical. - -### E2E (cross-repo, only meaningful in a fork triplet) - -- **Full dry-run.** Cut `rel-test` in a fork of openemr/openemr → confirm the - conductor's `openemr-rel-cut` dispatch lands here → confirm the rotation PR - opens with `next` advanced → tag in the fork → confirm `openemr-tag` - promotes `next`→`current`. -- **Race rehearsal.** Tag while a `workflow_dispatch` rotation is mid-run; - confirm the second event re-runs against the now-updated state and lands a - consistent PR. - -## Status - -Draft plan. Implementation lands in follow-up PRs once the conductor in -`openemr/openemr` is far enough along to emit the dispatch events this -workflow consumes. diff --git a/tools/release/README.md b/tools/release/README.md index cc8ec8a6..d9de17bb 100644 --- a/tools/release/README.md +++ b/tools/release/README.md @@ -14,8 +14,7 @@ tools/release/ ├── contracts/ JSON schemas vendored across consumer repos ├── scripts/ Operational shell probes (App-token sanity checks etc.) ├── templates/ PR body / changelog Twig templates -├── Taskfile.yml Glue between workflows and the PHP CLIs (`task release:*`) -└── versions.yml The 3-slot rotation registry (current/next/dev) +└── Taskfile.yml Glue between workflows and the PHP CLIs (`task release:*`) ``` Workflow steps in `.github/workflows/` are deliberately thin: mint App token, diff --git a/tools/release/Taskfile.yml b/tools/release/Taskfile.yml index cf7e9b9f..b134b35f 100644 --- a/tools/release/Taskfile.yml +++ b/tools/release/Taskfile.yml @@ -163,26 +163,6 @@ tasks: --repo={{shellQuote .REPO}} --notes-file={{shellQuote .OUTPUT_DIR}}/changelog.md - release:rotate: - desc: Apply 3-slot rotation per versions.yml (idempotent) - deps: [setup] - cmds: - - >- - php bin/rotate.php - --repo={{shellQuote (default "../.." .ROTATE_REPO)}} - {{if .CURRENT}}--current={{shellQuote .CURRENT}}{{end}} - {{if .NEXT}}--next={{shellQuote .NEXT}}{{end}} - {{if .DEV}}--dev={{shellQuote .DEV}}{{end}} - {{if eq .DRY_RUN "1"}}--dry-run{{end}} - - release:lint-versions: - desc: Verify every OpenEMR version pin is enumerated in versions.yml - deps: [setup] - cmds: - - >- - php bin/lint-versions.php - --repo={{shellQuote (default "../.." .ROTATE_REPO)}} - release:verify-tag: desc: Verify a release tag is annotated and well-formed per #664 spec requires: @@ -205,19 +185,6 @@ tasks: --consumer={{shellQuote .CONSUMER}} {{if .CANONICAL}}--canonical={{shellQuote .CANONICAL}}{{end}} - release:derive-slots-from-dispatch: - desc: Translate a repository_dispatch envelope into slot= lines - requires: - vars: [EVENT_TYPE] - vars: - PAYLOAD_FILE: '{{.PAYLOAD_FILE | default "-"}}' - deps: [setup] - cmds: - - >- - php bin/derive-slots-from-dispatch.php - --event-type={{shellQuote .EVENT_TYPE}} - --payload-file={{shellQuote .PAYLOAD_FILE}} - release:derive-announcement-inputs: desc: Emit version/tag/branch/forum_url lines for the announcements workflow deps: [setup] @@ -269,42 +236,8 @@ tasks: {{if .FORUM_URL}}--forum-url={{shellQuote .FORUM_URL}}{{end}} {{if .OUTPUT}}--output={{shellQuote .OUTPUT}}{{end}} - release:open-rotation-pr: - desc: Open or update the long-lived rotation draft PR - requires: - vars: [BRANCH, BASE] - deps: [setup] - cmds: - - >- - php bin/open-rotation-pr.php - --branch={{shellQuote .BRANCH}} - --base={{shellQuote .BASE}} - {{if .TITLE}}--title={{shellQuote .TITLE}}{{end}} - {{if .TRIGGER}}--trigger={{shellQuote .TRIGGER}}{{end}} - {{if .RUN_URL}}--run-url={{shellQuote .RUN_URL}}{{end}} - {{if .CURRENT}}--current={{shellQuote .CURRENT}}{{end}} - {{if .NEXT}}--next={{shellQuote .NEXT}}{{end}} - {{if .DEV}}--dev={{shellQuote .DEV}}{{end}} - - release:push-rotation-branch: - desc: Force-push the rotation working tree as a single bot commit - requires: - vars: [ROTATION_BRANCH, COMMIT_MESSAGE] - dir: '../..' - env: - GIT_AUTHOR_NAME: openemr-release-bot[bot] - GIT_AUTHOR_EMAIL: openemr-release-bot[bot]@users.noreply.github.com - GIT_COMMITTER_NAME: openemr-release-bot[bot] - GIT_COMMITTER_EMAIL: openemr-release-bot[bot]@users.noreply.github.com - cmds: - - git fetch origin {{shellQuote .ROTATION_BRANCH}} 2>/dev/null || true - - git checkout -B {{shellQuote .ROTATION_BRANCH}} - - git add -A - - git commit -m {{shellQuote .COMMIT_MESSAGE}} - - git push --force-with-lease origin {{shellQuote .ROTATION_BRANCH}} - release:ship: - desc: Merge the three release PRs (infra → conductor → docs) in order (issue #705) + desc: Merge the two release PRs (conductor → docs) in order (issue #705) requires: vars: [VERSION, REL_BRANCH] deps: [setup] diff --git a/tools/release/bin/derive-slots-from-dispatch.php b/tools/release/bin/derive-slots-from-dispatch.php deleted file mode 100644 index b0341d4e..00000000 --- a/tools/release/bin/derive-slots-from-dispatch.php +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env php - - * next= - * dev= - * - * Suitable for piping into $GITHUB_OUTPUT inside a workflow step. - * - * @package openemr-devops - * @link https://www.open-emr.org - * @author Michael A. Smith - * @copyright Copyright (c) 2026 OpenCoreEMR Inc. - * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 - */ - -declare(strict_types=1); - -require dirname(__DIR__) . '/vendor/autoload.php'; - -use OpenEMR\Release\SlotAssignmentParser; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\SingleCommandApplication; - -(new SingleCommandApplication()) - ->setName('derive-slots-from-dispatch') - ->setDescription('Convert a repository_dispatch envelope into slot= lines for rotate.php') - ->addOption('event-type', null, InputOption::VALUE_REQUIRED, 'Dispatch event type (e.g. openemr-rel-cut)') - ->addOption( - 'payload-file', - null, - InputOption::VALUE_REQUIRED, - "Path to JSON envelope (use '-' for stdin)", - ) - ->setCode(function (InputInterface $input, OutputInterface $output): int { - $eventType = $input->getOption('event-type'); - if (!is_string($eventType) || $eventType === '') { - $output->writeln('--event-type is required'); - return 1; - } - $payloadFile = $input->getOption('payload-file'); - if (!is_string($payloadFile) || $payloadFile === '') { - $output->writeln('--payload-file is required (use - for stdin)'); - return 1; - } - - if ($payloadFile === '-') { - $raw = (string) file_get_contents('php://stdin'); - } else { - if (!is_file($payloadFile)) { - $output->writeln(sprintf('Payload file not found: %s', $payloadFile)); - return 1; - } - $contents = file_get_contents($payloadFile); - if ($contents === false) { - $output->writeln(sprintf('Payload file unreadable: %s', $payloadFile)); - return 1; - } - $raw = $contents; - } - if ($raw === '') { - $output->writeln(sprintf('Empty payload from: %s', $payloadFile)); - return 1; - } - $envelope = json_decode($raw, true); - if (!is_array($envelope)) { - $output->writeln(sprintf('Payload is not a JSON object: %s', $payloadFile)); - return 1; - } - $normalized = []; - foreach ($envelope as $key => $value) { - if (!is_string($key)) { - $output->writeln(sprintf('Payload root has non-string key: %s', $payloadFile)); - return 1; - } - $normalized[$key] = $value; - } - - $assignments = (new SlotAssignmentParser())->fromDispatchPayload($eventType, $normalized); - foreach (['current', 'next', 'dev'] as $slot) { - $output->writeln(sprintf('%s=%s', $slot, $assignments[$slot] ?? '')); - } - return 0; - }) - ->run(); diff --git a/tools/release/bin/lint-versions.php b/tools/release/bin/lint-versions.php deleted file mode 100644 index 9b95d608..00000000 --- a/tools/release/bin/lint-versions.php +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env php - - * @copyright Copyright (c) 2026 OpenCoreEMR Inc. - * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 - */ - -declare(strict_types=1); - -require dirname(__DIR__) . '/vendor/autoload.php'; - -use OpenEMR\Release\VersionsRegistryLinter; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\SingleCommandApplication; - -(new SingleCommandApplication()) - ->setName('lint-versions') - ->setDescription('Verify every OpenEMR version pin is enumerated in versions.yml') - ->addOption( - 'repo', - null, - InputOption::VALUE_REQUIRED, - 'Path to the repo root', - getcwd() === false ? '.' : getcwd(), - ) - ->addOption( - 'registry', - null, - InputOption::VALUE_REQUIRED, - 'Path to versions.yml (defaults to /tools/release/versions.yml)', - ) - ->setCode(function (InputInterface $input, OutputInterface $output): int { - $repo = $input->getOption('repo'); - if (!is_string($repo) || $repo === '') { - $output->writeln('--repo is required'); - return 1; - } - $registry = $input->getOption('registry'); - if (!is_string($registry) || $registry === '') { - $registry = $repo . '/tools/release/versions.yml'; - } - if (!is_file($registry)) { - $output->writeln("Registry not found: {$registry}"); - return 1; - } - - $issues = (new VersionsRegistryLinter($repo, $registry))->lint(); - if ($issues === []) { - $output->writeln(' No drifted version pins.'); - return 0; - } - - $output->writeln(sprintf(' Found %d unregistered pin(s):', count($issues))); - foreach ($issues as $issue) { - $output->writeln(sprintf( - ' %s:%d [%s] %s', - $issue->path, - $issue->line, - $issue->patternKind, - $issue->matched, - )); - } - $output->writeln(''); - $output->writeln('Add each file to `files:` (under rotation) or `excludes:` (with reason) in versions.yml.'); - return 1; - }) - ->run(); diff --git a/tools/release/bin/open-rotation-pr.php b/tools/release/bin/open-rotation-pr.php deleted file mode 100644 index c6fa8f8b..00000000 --- a/tools/release/bin/open-rotation-pr.php +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env php - - * @copyright Copyright (c) 2026 OpenCoreEMR Inc. - * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 - */ - -declare(strict_types=1); - -require dirname(__DIR__) . '/vendor/autoload.php'; - -use OpenEMR\Release\RotationPrPublisher; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\SingleCommandApplication; - -(new SingleCommandApplication()) - ->setName('open-rotation-pr') - ->setDescription('Open or update the long-lived rotation draft PR') - ->addOption('branch', null, InputOption::VALUE_REQUIRED, 'Rotation branch name (head)') - ->addOption('base', null, InputOption::VALUE_REQUIRED, 'Base branch (e.g. master)') - ->addOption('title', null, InputOption::VALUE_REQUIRED, 'PR title', 'Release rotation (auto)') - ->addOption('trigger', null, InputOption::VALUE_REQUIRED, 'Dispatch type or trigger label', 'workflow_dispatch') - ->addOption('run-url', null, InputOption::VALUE_REQUIRED, 'Workflow run URL', '') - ->addOption('current', null, InputOption::VALUE_REQUIRED, 'New current slot (for body)', '') - ->addOption('next', null, InputOption::VALUE_REQUIRED, 'New next slot (for body)', '') - ->addOption('dev', null, InputOption::VALUE_REQUIRED, 'New dev slot (for body)', '') - ->setCode(function (InputInterface $input, OutputInterface $output): int { - $branch = RotationPrPublisher::stringOrEmpty($input->getOption('branch')); - $base = RotationPrPublisher::stringOrEmpty($input->getOption('base')); - if ($branch === '' || $base === '') { - $output->writeln('--branch and --base are required'); - return 1; - } - - $body = RotationPrPublisher::buildBody( - RotationPrPublisher::stringOrEmpty($input->getOption('trigger')), - RotationPrPublisher::stringOrEmpty($input->getOption('run-url')), - RotationPrPublisher::stringOrEmpty($input->getOption('current')), - RotationPrPublisher::stringOrEmpty($input->getOption('next')), - RotationPrPublisher::stringOrEmpty($input->getOption('dev')), - ); - $publisher = new RotationPrPublisher( - $branch, - $base, - RotationPrPublisher::stringOrEmpty($input->getOption('title')), - ); - $number = $publisher->publish($body); - $output->writeln(sprintf(' rotation PR #%d updated', $number)); - return 0; - }) - ->run(); diff --git a/tools/release/bin/rotate.php b/tools/release/bin/rotate.php deleted file mode 100644 index 506a3d22..00000000 --- a/tools/release/bin/rotate.php +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env php - - * @copyright Copyright (c) 2026 OpenCoreEMR Inc. - * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 - */ - -declare(strict_types=1); - -require dirname(__DIR__) . '/vendor/autoload.php'; - -use OpenEMR\Release\SlotAssignmentParser; -use OpenEMR\Release\SlotRotator; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\SingleCommandApplication; - -(new SingleCommandApplication()) - ->setName('rotate') - ->setDescription('Apply OpenEMR slot rotation per tools/release/versions.yml') - ->addOption( - 'repo', - null, - InputOption::VALUE_REQUIRED, - 'Path to the repo root', - getcwd() === false ? '.' : getcwd(), - ) - ->addOption( - 'registry', - null, - InputOption::VALUE_REQUIRED, - 'Path to versions.yml (defaults to /tools/release/versions.yml)', - ) - ->addOption('current', null, InputOption::VALUE_REQUIRED, 'New value for the current slot (e.g. "8.1")') - ->addOption('next', null, InputOption::VALUE_REQUIRED, 'New value for the next slot (e.g. "8.2")') - ->addOption('dev', null, InputOption::VALUE_REQUIRED, 'New value for the dev slot (e.g. "8.2" or "edge")') - ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Print the diff and exit without writing') - ->setCode(function (InputInterface $input, OutputInterface $output): int { - $repo = $input->getOption('repo'); - if (!is_string($repo) || $repo === '') { - $output->writeln('--repo is required'); - return 1; - } - $registry = $input->getOption('registry'); - if (!is_string($registry) || $registry === '') { - $registry = $repo . '/tools/release/versions.yml'; - } - if (!is_file($registry)) { - $output->writeln("Registry not found: {$registry}"); - return 1; - } - - $parser = new SlotAssignmentParser(); - $assignments = []; - foreach (['current', 'next', 'dev'] as $slot) { - $raw = $input->getOption($slot); - if (!is_string($raw) || $raw === '') { - continue; - } - $assignments[$slot] = $parser->parse($slot, $raw); - } - if ($assignments === []) { - $output->writeln('No slot assignments provided; nothing to do.'); - return 0; - } - - $dryRun = (bool) $input->getOption('dry-run'); - $result = (new SlotRotator($repo, $registry))->rotate($assignments, $dryRun); - - if ($result->isNoOp()) { - $output->writeln('No changes required (already at target slot values).'); - return 0; - } - - foreach ($result->changedFiles as $path) { - $output->writeln(($dryRun ? '[dry-run] ' : '') . 'changed: ' . $path); - } - return 0; - }) - ->run(); diff --git a/tools/release/bin/ship-release.php b/tools/release/bin/ship-release.php index 9a87bdc3..44ced0ba 100644 --- a/tools/release/bin/ship-release.php +++ b/tools/release/bin/ship-release.php @@ -2,10 +2,10 @@ setName('ship-release') - ->setDescription('Merge the three release PRs in order (issue #705)') + ->setDescription('Merge the two release PRs in order (issue #705)') // Option is `--release-version`, not `--version`: Symfony Console reserves // `--version`/`-V` as a global flag that prints the app name and exits 0 // before the command runs, so `--version=8.1.0` would silently no-op. diff --git a/tools/release/src/AppPermissionProbe.php b/tools/release/src/AppPermissionProbe.php index 9f78fe3c..6f304262 100644 --- a/tools/release/src/AppPermissionProbe.php +++ b/tools/release/src/AppPermissionProbe.php @@ -105,9 +105,10 @@ public function checkContentsWrite( } // Committing under .github/workflows/ additionally requires - // workflows:write. Rotation rewrites build-{800,810,811}.yml, so this is - // the gap that broke release-rotation.yml; a plain-dotfile probe misses - // it. The stub is name-only with no `on:` trigger, so it never runs. + // workflows:write. Plain-dotfile probes miss this gap, and several + // release-mechanism workflows (build-release, release-announcements, + // ship-release) depend on it. The stub is name-only with no `on:` + // trigger, so it never runs. try { $this->api->putFile( $owner, diff --git a/tools/release/src/LintIssue.php b/tools/release/src/LintIssue.php deleted file mode 100644 index 17ce73d8..00000000 --- a/tools/release/src/LintIssue.php +++ /dev/null @@ -1,29 +0,0 @@ - - * @copyright Copyright (c) 2026 OpenCoreEMR Inc. - * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 - */ - -declare(strict_types=1); - -namespace OpenEMR\Release; - -final readonly class LintIssue -{ - public function __construct( - public string $path, - public int $line, - public string $lineContent, - public string $matched, - public string $patternKind, - ) { - } -} diff --git a/tools/release/src/PullRequestTarget.php b/tools/release/src/PullRequestTarget.php index 61cb78f1..8ae7cfb2 100644 --- a/tools/release/src/PullRequestTarget.php +++ b/tools/release/src/PullRequestTarget.php @@ -27,19 +27,18 @@ public function __construct( } /** - * Build the canonical infra → conductor → docs target list for a release. + * Build the canonical conductor → docs target list for a release. * * Branch name conventions are defined in openemr-devops#705 and #664. - * Conductor merges into the rel- branch, the other two into master. + * Conductor merges into the rel- branch, docs into master. * * @return list */ public static function forRelease(string $version, string $relBranch): array { return [ - new self('openemr/openemr-devops', 'release-rotation/auto', 'master', RoleLabel::Infra, 1), - new self('openemr/openemr', "release-prep/{$relBranch}", $relBranch, RoleLabel::Conductor, 2), - new self('openemr/website-openemr', "release-docs/{$version}", 'master', RoleLabel::Docs, 3), + new self('openemr/openemr', "release-prep/{$relBranch}", $relBranch, RoleLabel::Conductor, 1), + new self('openemr/website-openemr', "release-docs/{$version}", 'master', RoleLabel::Docs, 2), ]; } } diff --git a/tools/release/src/RoleLabel.php b/tools/release/src/RoleLabel.php index 0f9a9da9..c40dfc70 100644 --- a/tools/release/src/RoleLabel.php +++ b/tools/release/src/RoleLabel.php @@ -1,7 +1,7 @@ - * @copyright Copyright (c) 2026 OpenCoreEMR Inc. - * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 - */ - -declare(strict_types=1); - -namespace OpenEMR\Release; - -use Symfony\Component\Process\Process; - -final readonly class RotationPrPublisher -{ - public function __construct( - private string $branch, - private string $base, - private string $title = 'Release rotation (auto)', - ) { - } - - /** - * Returns the PR number that was created or updated. - */ - public function publish(string $body): int - { - $bodyFile = tempnam(sys_get_temp_dir(), 'rotation-pr-body-'); - if ($bodyFile === false) { - throw new \RuntimeException('Failed to allocate temp file for PR body'); - } - file_put_contents($bodyFile, $body); - - try { - $existing = $this->findOpenPrNumber(); - if ($existing !== null) { - $this->run(['gh', 'pr', 'edit', (string) $existing, '--body-file', $bodyFile]); - return $existing; - } - $this->run([ - 'gh', 'pr', 'create', - '--draft', - '--base', $this->base, - '--head', $this->branch, - '--title', $this->title, - '--body-file', $bodyFile, - ]); - $created = $this->findOpenPrNumber(); - if ($created === null) { - throw new \RuntimeException('PR was created but cannot be located by head/base'); - } - return $created; - } finally { - @unlink($bodyFile); - } - } - - public static function stringOrEmpty(mixed $value): string - { - return is_string($value) ? $value : ''; - } - - public static function buildBody( - string $trigger, - string $runUrl, - string $current, - string $next, - string $dev, - ): string { - $lines = [ - sprintf('Automated rotation triggered by `%s`.', $trigger), - '', - ]; - if ($runUrl !== '') { - $lines[] = sprintf('Run: %s', $runUrl); - $lines[] = ''; - } - $lines[] = 'Slot assignments applied:'; - foreach (['current' => $current, 'next' => $next, 'dev' => $dev] as $slot => $value) { - if ($value !== '') { - $lines[] = sprintf('- %s=%s', $slot, $value); - } - } - return implode("\n", $lines) . "\n"; - } - - private function findOpenPrNumber(): ?int - { - $process = new Process([ - 'gh', 'pr', 'list', - '--state', 'open', - '--head', $this->branch, - '--base', $this->base, - '--json', 'number', - '--jq', '.[0].number // ""', - ]); - $process->mustRun(); - $out = trim($process->getOutput()); - return $out === '' ? null : (int) $out; - } - - /** - * @param list $argv - */ - private function run(array $argv): void - { - $process = new Process($argv); - $process->setTimeout(120.0); - $process->mustRun(); - } -} diff --git a/tools/release/src/ShipReleaseOrchestrator.php b/tools/release/src/ShipReleaseOrchestrator.php index e5fe8c5a..b1bd85fe 100644 --- a/tools/release/src/ShipReleaseOrchestrator.php +++ b/tools/release/src/ShipReleaseOrchestrator.php @@ -1,7 +1,7 @@ $targets infra, conductor, docs (any order — sorted internally) + * @param list $targets conductor, docs (any order — sorted internally) */ public function ship(array $targets): ShipReleaseResult { @@ -68,7 +68,7 @@ public function ship(array $targets): ShipReleaseResult /** * Defensive sort + uniqueness check so a caller passing targets in any - * order still gets infra → conductor → docs at merge time. + * order still gets conductor → docs at merge time. * * @param list $targets * @return list diff --git a/tools/release/src/SlotAssignmentParser.php b/tools/release/src/SlotAssignmentParser.php deleted file mode 100644 index 65c2595b..00000000 --- a/tools/release/src/SlotAssignmentParser.php +++ /dev/null @@ -1,100 +0,0 @@ - - * @copyright Copyright (c) 2026 OpenCoreEMR Inc. - * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 - */ - -declare(strict_types=1); - -namespace OpenEMR\Release; - -final class SlotAssignmentParser -{ - /** - * @return array - */ - public function parse(string $slot, string $raw): array - { - if (str_contains($raw, '=')) { - return $this->parseKeyValue($raw); - } - if ($raw === 'edge') { - return ['branch' => 'master']; - } - if (preg_match('/^(\d+)\.(\d+)$/', $raw, $m) !== 1) { - throw new \InvalidArgumentException("Slot {$slot}: expected MAJOR.MINOR or 'edge', got: {$raw}"); - } - $major = $m[1]; - $minorPart = $m[2]; - return [ - 'minor' => "{$major}.{$minorPart}", - 'full' => "{$major}.{$minorPart}.0", - 'branch' => "rel-{$major}{$minorPart}0", - 'docker_dir' => "{$major}.{$minorPart}.0", - ]; - } - - /** - * Translate a `repository_dispatch` envelope (per dispatch.schema.json) - * into the `--current=… --next=… --dev=…` shape that bin/rotate.php - * wants. Returns a map keyed by slot name with bare CLI values - * (MAJOR.MINOR strings or 'edge'); the workflow then re-invokes the - * regular `parse()` path for each entry. - * - * Mapping: - * - openemr-rel-cut, openemr-rel-update → next= - * - openemr-tag → current= - * - openemr-docs-binaries → no slot move (consumer event) - * - * @param array $envelope - * @return array - */ - public function fromDispatchPayload(string $eventType, array $envelope): array - { - $data = $envelope['data'] ?? null; - if (!is_array($data)) { - throw new \InvalidArgumentException("Dispatch envelope missing 'data' object"); - } - if ($eventType === 'openemr-docs-binaries') { - return []; - } - - $version = $data['version'] ?? null; - if (!is_string($version) || preg_match('/^(\d+)\.(\d+)\.\d+$/', $version, $m) !== 1) { - throw new \InvalidArgumentException( - "Dispatch '{$eventType}' missing or malformed data.version (expected MAJOR.MINOR.PATCH)", - ); - } - $minor = "{$m[1]}.{$m[2]}"; - - return match ($eventType) { - 'openemr-rel-cut', 'openemr-rel-update' => ['next' => $minor], - 'openemr-tag' => ['current' => $minor], - default => throw new \InvalidArgumentException("Unsupported dispatch event type: {$eventType}"), - }; - } - - /** - * @return array - */ - private function parseKeyValue(string $raw): array - { - $out = []; - foreach (explode(',', $raw) as $pair) { - [$key, $value] = array_pad(explode('=', $pair, 2), 2, ''); - $out[trim($key)] = trim($value); - } - return $out; - } -} diff --git a/tools/release/src/SlotRotationResult.php b/tools/release/src/SlotRotationResult.php deleted file mode 100644 index c1044ed2..00000000 --- a/tools/release/src/SlotRotationResult.php +++ /dev/null @@ -1,35 +0,0 @@ - - * @copyright Copyright (c) 2026 OpenCoreEMR Inc. - * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 - */ - -declare(strict_types=1); - -namespace OpenEMR\Release; - -final readonly class SlotRotationResult -{ - /** - * @param list $changedFiles paths relative to repo root - * @param array $snapshots keyed by relative path - */ - public function __construct( - public array $changedFiles, - public array $snapshots, - ) { - } - - public function isNoOp(): bool - { - return $this->changedFiles === []; - } -} diff --git a/tools/release/src/SlotRotator.php b/tools/release/src/SlotRotator.php deleted file mode 100644 index 6b745930..00000000 --- a/tools/release/src/SlotRotator.php +++ /dev/null @@ -1,265 +0,0 @@ - is re-pointed at the new version dir. That symlink is - * the source of truth the consolidated build workflow (build-openemr.yml) - * reads to resolve each slot's version, so flipping it is how a rotation moves - * the build — no version strings live in the workflow itself. - * - * @package openemr-devops - * @link https://www.open-emr.org - * @author Michael A. Smith - * @copyright Copyright (c) 2026 OpenCoreEMR Inc. - * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 - */ - -declare(strict_types=1); - -namespace OpenEMR\Release; - -use Symfony\Component\Yaml\Yaml; - -final readonly class SlotRotator -{ - public function __construct( - private string $repoDir, - private string $registryPath, - ) { - } - - /** - * @param array> $newSlots slot name → key/value map - * (e.g. ['next' => ['minor' => '8.2', ...]]) - */ - public function rotate(array $newSlots, bool $dryRun = false): SlotRotationResult - { - $registry = $this->loadRegistry(); - $oldSlots = $registry['slots']; - - /** @var list $changed */ - $changed = []; - /** @var array $snapshots */ - $snapshots = []; - - foreach ($newSlots as $slotName => $newDef) { - if (!isset($oldSlots[$slotName])) { - throw new \InvalidArgumentException("Unknown slot: {$slotName}"); - } - $oldDef = $oldSlots[$slotName]; - if ($oldDef === $newDef) { - continue; - } - - $replacements = $this->buildReplacements($oldDef, $newDef); - $affected = $this->filesForSlot($registry['files'], $slotName); - - foreach ($affected as $relPath) { - $absPath = $this->repoDir . '/' . $relPath; - if (!is_file($absPath)) { - throw new \RuntimeException("Registry references missing file: {$relPath}"); - } - $before = (string) file_get_contents($absPath); - $after = $this->applyReplacements($before, $replacements); - if ($after === $before) { - continue; - } - if (!$dryRun) { - file_put_contents($absPath, $after); - } - $changed[] = $relPath; - $snapshots[$relPath] = ['before' => $before, 'after' => $after]; - } - - $newDockerDir = $newDef['docker_dir'] ?? ''; - if ($newDockerDir !== '' && $newDockerDir !== ($oldDef['docker_dir'] ?? '')) { - $linkRel = 'docker/openemr/' . $slotName; - $snapshot = $this->repointSlotSymlink($linkRel, $newDockerDir, $dryRun); - if ($snapshot !== null) { - $changed[] = $linkRel; - $snapshots[$linkRel] = $snapshot; - } - } - } - - $registryRel = $this->relativePath($this->registryPath); - $regBefore = (string) file_get_contents($this->registryPath); - $regAfter = $this->updateRegistrySlots($regBefore, $newSlots); - if ($regAfter !== $regBefore) { - if (!$dryRun) { - file_put_contents($this->registryPath, $regAfter); - } - $changed[] = $registryRel; - $snapshots[$registryRel] = ['before' => $regBefore, 'after' => $regAfter]; - } - - return new SlotRotationResult($changed, $snapshots); - } - - /** - * @return array{ - * slots: array>, - * files: list}> - * } - */ - private function loadRegistry(): array - { - $parsed = Yaml::parseFile($this->registryPath); - if (!is_array($parsed) || !isset($parsed['slots'], $parsed['files'])) { - throw new \RuntimeException("Registry malformed: {$this->registryPath}"); - } - /** @var array{slots: array>, files: list}>} $parsed */ - return $parsed; - } - - /** - * @param array $oldDef - * @param array $newDef - * @return array old → new, ordered by old length descending - */ - private function buildReplacements(array $oldDef, array $newDef): array - { - $pairs = []; - foreach ($newDef as $key => $newValue) { - if (!isset($oldDef[$key])) { - continue; - } - $oldValue = $oldDef[$key]; - if ($oldValue === '' || $oldValue === $newValue) { - continue; - } - $pairs[$oldValue] = $newValue; - } - uksort($pairs, static fn(string $a, string $b): int => strlen($b) - strlen($a)); - return $pairs; - } - - /** - * @param list}> $files - * @return list - */ - private function filesForSlot(array $files, string $slotName): array - { - $matched = []; - foreach ($files as $entry) { - if ($entry['slot'] === $slotName || $entry['slot'] === 'all') { - $matched[] = $entry['path']; - } - } - return $matched; - } - - /** - * @param array $replacements - */ - private function applyReplacements(string $content, array $replacements): string - { - foreach ($replacements as $old => $new) { - $pattern = '/(?> $newSlots - */ - private function updateRegistrySlots(string $content, array $newSlots): string - { - $lines = explode("\n", $content); - $inSlotsBlock = false; - $currentSlot = null; - foreach ($lines as $i => $line) { - if (preg_match('/^slots:\s*$/', $line) === 1) { - $inSlotsBlock = true; - $currentSlot = null; - continue; - } - if ($inSlotsBlock && preg_match('/^\S/', $line) === 1) { - $inSlotsBlock = false; - $currentSlot = null; - continue; - } - if (!$inSlotsBlock) { - continue; - } - if (preg_match('/^ (\w+):\s*$/', $line, $m) === 1) { - $currentSlot = isset($newSlots[$m[1]]) ? $m[1] : null; - continue; - } - if ($currentSlot === null) { - continue; - } - if (preg_match('/^( (\w+):\s*)"([^"]*)"(\s*)$/', $line, $m) === 1) { - $key = $m[2]; - if (!isset($newSlots[$currentSlot][$key])) { - continue; - } - $lines[$i] = $m[1] . '"' . $newSlots[$currentSlot][$key] . '"' . $m[4]; - } - } - return implode("\n", $lines); - } - - /** - * Re-point a slot symlink (docker/openemr/) at the slot's new - * docker_dir. This is the source of truth the consolidated build workflow - * reads, replacing the old per-slot build-workflow pin rewriting. Returns a - * before/after snapshot of the symlink target, or null if already correct - * (idempotence). - * - * @return array{before: string, after: string}|null - */ - private function repointSlotSymlink(string $linkRel, string $newTarget, bool $dryRun): ?array - { - $linkPath = $this->repoDir . '/' . $linkRel; - $current = null; - if (is_link($linkPath)) { - $target = readlink($linkPath); - $current = $target === false ? null : $target; - } - if ($current === $newTarget) { - return null; - } - if (!$dryRun) { - if (is_link($linkPath) || file_exists($linkPath)) { - unlink($linkPath); - } - if (!symlink($newTarget, $linkPath)) { - throw new \RuntimeException("Failed to re-point symlink: {$linkRel}"); - } - } - return ['before' => $current ?? '', 'after' => $newTarget]; - } - - private function relativePath(string $absPath): string - { - $prefix = rtrim($this->repoDir, '/') . '/'; - if (str_starts_with($absPath, $prefix)) { - return substr($absPath, strlen($prefix)); - } - return $absPath; - } -} diff --git a/tools/release/src/VersionsRegistryLinter.php b/tools/release/src/VersionsRegistryLinter.php deleted file mode 100644 index b345115d..00000000 --- a/tools/release/src/VersionsRegistryLinter.php +++ /dev/null @@ -1,180 +0,0 @@ -` Docker image reference - * - `OPENEMR_VERSION=` build arg - * - `--branch ` git clone branch - * - `rel-NNN` bare release-branch reference - * - `docker/openemr/` path reference - * - * @package openemr-devops - * @link https://www.open-emr.org - * @author Michael A. Smith - * @copyright Copyright (c) 2026 OpenCoreEMR Inc. - * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 - */ - -declare(strict_types=1); - -namespace OpenEMR\Release; - -use Symfony\Component\Process\Process; -use Symfony\Component\Yaml\Yaml; - -final readonly class VersionsRegistryLinter -{ - /** - * Patterns that signal an OpenEMR version pin. Keys are kind labels for - * reporting; values are PCRE patterns where capture group 1 is the matched - * value to surface in the lint message. - */ - private const PATTERNS = [ - 'docker_image_tag' => '#openemr/openemr:([\w.-]+)#', - 'build_arg' => '/OPENEMR_VERSION=([\w.-]+)/', - 'clone_branch' => '/--branch\s+(rel-\d+)/', - 'rel_branch_ref' => '/(? '#(? - */ - public function lint(): array - { - $registry = $this->loadRegistry(); - $covered = array_column($registry['files'], 'path'); - $excluded = array_column($registry['excludes'], 'path'); - - $registryRel = $this->relativeRegistryPath(); - $issues = []; - foreach ($this->trackedFiles() as $relPath) { - if ($relPath === $registryRel) { - continue; - } - if (in_array($relPath, $covered, true)) { - continue; - } - if ($this->isExcluded($relPath, $excluded)) { - continue; - } - foreach ($this->scanFile($relPath) as $issue) { - $issues[] = $issue; - } - } - return $issues; - } - - /** - * @return array{files: list, excludes: list} - */ - private function loadRegistry(): array - { - $parsed = Yaml::parseFile($this->registryPath); - if (!is_array($parsed)) { - throw new \RuntimeException("Registry malformed: {$this->registryPath}"); - } - $files = $parsed['files'] ?? []; - $excludes = $parsed['excludes'] ?? []; - if (!is_array($files) || !is_array($excludes)) { - throw new \RuntimeException("Registry files/excludes must be lists: {$this->registryPath}"); - } - /** @var array{files: list, excludes: list} $out */ - $out = ['files' => array_values($files), 'excludes' => array_values($excludes)]; - return $out; - } - - /** - * @return list - */ - private function trackedFiles(): array - { - $process = new Process(['git', 'ls-files', '-z'], $this->repoDir); - $process->mustRun(); - $raw = $process->getOutput(); - if ($raw === '') { - return []; - } - return array_values(array_filter( - explode("\0", $raw), - static fn(string $s): bool => $s !== '', - )); - } - - /** - * @param list $excluded - */ - private function isExcluded(string $path, array $excluded): bool - { - foreach ($excluded as $entry) { - if ($path === $entry) { - return true; - } - if (str_starts_with($path, $entry . '/')) { - return true; - } - } - return false; - } - - /** - * @return list - */ - private function scanFile(string $relPath): array - { - $absPath = $this->repoDir . '/' . $relPath; - if (!is_file($absPath)) { - return []; - } - if ($this->looksBinary($relPath)) { - return []; - } - $content = (string) file_get_contents($absPath); - if ($content === '') { - return []; - } - - $issues = []; - $lines = explode("\n", $content); - foreach ($lines as $idx => $line) { - foreach (self::PATTERNS as $kind => $pattern) { - if (preg_match_all($pattern, $line, $matches, PREG_SET_ORDER) === 0) { - continue; - } - foreach ($matches as $m) { - $issues[] = new LintIssue($relPath, $idx + 1, $line, $m[1], $kind); - } - } - } - return $issues; - } - - private function looksBinary(string $relPath): bool - { - return preg_match('/\.(png|jpe?g|gif|ico|woff2?|ttf|eot|pdf|zip|gz|bz2|tar|jar|class)$/i', $relPath) === 1; - } - - private function relativeRegistryPath(): string - { - $prefix = rtrim($this->repoDir, '/') . '/'; - if (str_starts_with($this->registryPath, $prefix)) { - return substr($this->registryPath, strlen($prefix)); - } - return $this->registryPath; - } -} diff --git a/tools/release/tests/PullRequestTargetTest.php b/tools/release/tests/PullRequestTargetTest.php index 446b2224..efc2600d 100644 --- a/tools/release/tests/PullRequestTargetTest.php +++ b/tools/release/tests/PullRequestTargetTest.php @@ -18,27 +18,21 @@ final class PullRequestTargetTest extends TestCase { - public function testForReleaseProducesInfraConductorDocsInOrder(): void + public function testForReleaseProducesConductorDocsInOrder(): void { $targets = PullRequestTarget::forRelease('8.1.0', 'rel-810'); - self::assertCount(3, $targets); - self::assertSame(RoleLabel::Infra, $targets[0]->roleLabel); - self::assertSame('openemr/openemr-devops', $targets[0]->repo); - self::assertSame('release-rotation/auto', $targets[0]->branch); - self::assertSame('master', $targets[0]->expectedBase); + self::assertCount(2, $targets); + self::assertSame(RoleLabel::Conductor, $targets[0]->roleLabel); + self::assertSame('openemr/openemr', $targets[0]->repo); + self::assertSame('release-prep/rel-810', $targets[0]->branch); + self::assertSame('rel-810', $targets[0]->expectedBase); self::assertSame(1, $targets[0]->mergeOrder); - self::assertSame(RoleLabel::Conductor, $targets[1]->roleLabel); - self::assertSame('openemr/openemr', $targets[1]->repo); - self::assertSame('release-prep/rel-810', $targets[1]->branch); - self::assertSame('rel-810', $targets[1]->expectedBase); + self::assertSame(RoleLabel::Docs, $targets[1]->roleLabel); + self::assertSame('openemr/website-openemr', $targets[1]->repo); + self::assertSame('release-docs/8.1.0', $targets[1]->branch); + self::assertSame('master', $targets[1]->expectedBase); self::assertSame(2, $targets[1]->mergeOrder); - - self::assertSame(RoleLabel::Docs, $targets[2]->roleLabel); - self::assertSame('openemr/website-openemr', $targets[2]->repo); - self::assertSame('release-docs/8.1.0', $targets[2]->branch); - self::assertSame('master', $targets[2]->expectedBase); - self::assertSame(3, $targets[2]->mergeOrder); } } diff --git a/tools/release/tests/RotationPrPublisherTest.php b/tools/release/tests/RotationPrPublisherTest.php deleted file mode 100644 index c973d1ac..00000000 --- a/tools/release/tests/RotationPrPublisherTest.php +++ /dev/null @@ -1,56 +0,0 @@ - - * @copyright Copyright (c) 2026 OpenCoreEMR Inc. - * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 - */ - -declare(strict_types=1); - -namespace OpenEMR\Release\Tests; - -use OpenEMR\Release\RotationPrPublisher; -use PHPUnit\Framework\TestCase; - -final class RotationPrPublisherTest extends TestCase -{ - public function testBodyIncludesTriggerInBackticksAndRunUrl(): void - { - $body = RotationPrPublisher::buildBody( - 'openemr-rel-cut', - 'https://example.test/runs/42', - '', - '8.2', - '', - ); - - self::assertStringContainsString('triggered by `openemr-rel-cut`', $body); - self::assertStringContainsString('Run: https://example.test/runs/42', $body); - self::assertStringContainsString('- next=8.2', $body); - self::assertStringNotContainsString('- current=', $body); - self::assertStringNotContainsString('- dev=', $body); - } - - public function testBodyOmitsRunUrlSectionWhenAbsent(): void - { - $body = RotationPrPublisher::buildBody('workflow_dispatch', '', '8.1', '8.2', 'edge'); - - self::assertStringNotContainsString('Run:', $body); - self::assertStringContainsString('- current=8.1', $body); - self::assertStringContainsString('- next=8.2', $body); - self::assertStringContainsString('- dev=edge', $body); - } - - public function testBodyHandlesAllSlotsEmpty(): void - { - $body = RotationPrPublisher::buildBody('workflow_dispatch', 'https://x.test/r/1', '', '', ''); - - // Heading line + blank + Run line + blank + 'Slot assignments applied:' + trailing newline. - self::assertStringContainsString('Slot assignments applied:', $body); - self::assertStringEndsWith("\n", $body); - self::assertStringNotContainsString('- ', $body); - } -} diff --git a/tools/release/tests/ShipReleaseOrchestratorTest.php b/tools/release/tests/ShipReleaseOrchestratorTest.php index 7c401172..a304cdf3 100644 --- a/tools/release/tests/ShipReleaseOrchestratorTest.php +++ b/tools/release/tests/ShipReleaseOrchestratorTest.php @@ -27,8 +27,6 @@ final class ShipReleaseOrchestratorTest extends TestCase { - private const INFRA_REPO = 'openemr/openemr-devops'; - private const INFRA_BRANCH = 'release-rotation/auto'; private const CONDUCTOR_REPO = 'openemr/openemr'; private const CONDUCTOR_BRANCH = 'release-prep/rel-810'; private const CONDUCTOR_BASE = 'rel-810'; @@ -73,11 +71,10 @@ private function mergedConductor(): PullRequestSnapshot return $this->merged(202, 'sha-conductor', self::CONDUCTOR_BASE); } - public function testHappyPathMergesAllThreeInOrderAndPostsApprovalStatus(): void + public function testHappyPathMergesBothInOrderAndPostsApprovalStatus(): void { $api = new FakePullRequestApi(); $targets = $this->targets(); - $api->setSnapshot(self::INFRA_REPO, self::INFRA_BRANCH, $this->open(101, 'sha-infra')); $api->setSnapshot(self::CONDUCTOR_REPO, self::CONDUCTOR_BRANCH, $this->openConductor()); $api->setSnapshot(self::DOCS_REPO, self::DOCS_BRANCH, $this->open(303, 'sha-docs-old')); // After conductor merge, the docs PR is re-rendered with a new head SHA. @@ -87,7 +84,6 @@ public function testHappyPathMergesAllThreeInOrderAndPostsApprovalStatus(): void 2, $this->open(303, 'sha-docs-new'), ); - $api->setReadiness(self::INFRA_REPO, 101, $this->ready('sha-infra')); $api->setReadiness(self::CONDUCTOR_REPO, 202, $this->ready('sha-conductor')); $api->setReadiness(self::DOCS_REPO, 303, $this->ready('sha-docs-new')); @@ -95,56 +91,28 @@ public function testHappyPathMergesAllThreeInOrderAndPostsApprovalStatus(): void self::assertTrue($result->wasSuccessful()); self::assertSame( - [ShipReleaseStepStatus::MERGED, ShipReleaseStepStatus::MERGED, ShipReleaseStepStatus::MERGED], + [ShipReleaseStepStatus::MERGED, ShipReleaseStepStatus::MERGED], array_map( static fn (ShipReleaseStepResult $s): ShipReleaseStepStatus => $s->status, $result->steps, ), ); self::assertSame( - [['repo' => self::INFRA_REPO, 'number' => 101, 'expected' => 'sha-infra'], - ['repo' => self::CONDUCTOR_REPO, 'number' => 202, 'expected' => 'sha-conductor'], + [['repo' => self::CONDUCTOR_REPO, 'number' => 202, 'expected' => 'sha-conductor'], ['repo' => self::DOCS_REPO, 'number' => 303, 'expected' => 'sha-docs-new']], $api->merges, ); - self::assertCount(3, $api->postedStatuses); + self::assertCount(2, $api->postedStatuses); self::assertSame(ShipReleaseOrchestrator::STATUS_CONTEXT, $api->postedStatuses[0]['context']); - self::assertSame('sha-infra', $api->postedStatuses[0]['sha']); - self::assertSame('sha-docs-new', $api->postedStatuses[2]['sha']); - } - - public function testInfraAlreadyMergedSkipsThenContinues(): void - { - $api = new FakePullRequestApi(); - $api->setSnapshot(self::INFRA_REPO, self::INFRA_BRANCH, $this->merged(101, 'sha-infra')); - $api->setSnapshot(self::CONDUCTOR_REPO, self::CONDUCTOR_BRANCH, $this->openConductor()); - $api->setSnapshot(self::DOCS_REPO, self::DOCS_BRANCH, $this->open(303, 'sha-docs-old')); - // After conductor merges, the docs PR is re-rendered (DRAFT→FINAL). - $api->setSnapshotAfterFinds( - self::DOCS_REPO, - self::DOCS_BRANCH, - 2, - $this->open(303, 'sha-docs-new'), - ); - $api->setReadiness(self::CONDUCTOR_REPO, 202, $this->ready('sha-conductor')); - $api->setReadiness(self::DOCS_REPO, 303, $this->ready('sha-docs-new')); - - $result = (new ShipReleaseOrchestrator($api, new FakeClock()))->ship($this->targets()); - - self::assertTrue($result->wasSuccessful()); - self::assertSame(ShipReleaseStepStatus::SKIPPED_ALREADY_MERGED, $result->steps[0]->status); - self::assertSame(ShipReleaseStepStatus::MERGED, $result->steps[1]->status); - self::assertSame(ShipReleaseStepStatus::MERGED, $result->steps[2]->status); - self::assertCount(2, $api->merges); + self::assertSame('sha-conductor', $api->postedStatuses[0]['sha']); + self::assertSame('sha-docs-new', $api->postedStatuses[1]['sha']); } public function testConductorBlockedAtPreflightMergesNothing(): void { $api = new FakePullRequestApi(); - $api->setSnapshot(self::INFRA_REPO, self::INFRA_BRANCH, $this->open(101, 'sha-infra')); $api->setSnapshot(self::CONDUCTOR_REPO, self::CONDUCTOR_BRANCH, $this->openConductor()); $api->setSnapshot(self::DOCS_REPO, self::DOCS_BRANCH, $this->open(303, 'sha-docs')); - $api->setReadiness(self::INFRA_REPO, 101, $this->ready('sha-infra')); $api->setReadiness( self::CONDUCTOR_REPO, 202, @@ -155,21 +123,18 @@ public function testConductorBlockedAtPreflightMergesNothing(): void $result = (new ShipReleaseOrchestrator($api, new FakeClock()))->ship($this->targets()); self::assertFalse($result->wasSuccessful()); - self::assertSame(ShipReleaseStepStatus::NOT_REACHED, $result->steps[0]->status); - self::assertSame(ShipReleaseStepStatus::BLOCKED, $result->steps[1]->status); - self::assertContains('check core-test conclusion=FAILURE', $result->steps[1]->reasons); - self::assertSame(ShipReleaseStepStatus::NOT_REACHED, $result->steps[2]->status); + self::assertSame(ShipReleaseStepStatus::BLOCKED, $result->steps[0]->status); + self::assertContains('check core-test conclusion=FAILURE', $result->steps[0]->reasons); + self::assertSame(ShipReleaseStepStatus::NOT_REACHED, $result->steps[1]->status); self::assertSame([], $api->merges); self::assertSame([], $api->postedStatuses); } - public function testInfraReadyButDocsBlockedAtPreflightMergesNothing(): void + public function testConductorReadyButDocsBlockedAtPreflightMergesNothing(): void { $api = new FakePullRequestApi(); - $api->setSnapshot(self::INFRA_REPO, self::INFRA_BRANCH, $this->open(101, 'sha-infra')); $api->setSnapshot(self::CONDUCTOR_REPO, self::CONDUCTOR_BRANCH, $this->openConductor()); $api->setSnapshot(self::DOCS_REPO, self::DOCS_BRANCH, $this->open(303, 'sha-docs')); - $api->setReadiness(self::INFRA_REPO, 101, $this->ready('sha-infra')); $api->setReadiness(self::CONDUCTOR_REPO, 202, $this->ready('sha-conductor')); $api->setReadiness(self::DOCS_REPO, 303, new PullRequestReadiness( 'sha-docs', @@ -180,15 +145,13 @@ public function testInfraReadyButDocsBlockedAtPreflightMergesNothing(): void self::assertFalse($result->wasSuccessful()); self::assertSame(ShipReleaseStepStatus::NOT_REACHED, $result->steps[0]->status); - self::assertSame(ShipReleaseStepStatus::NOT_REACHED, $result->steps[1]->status); - self::assertSame(ShipReleaseStepStatus::BLOCKED, $result->steps[2]->status); + self::assertSame(ShipReleaseStepStatus::BLOCKED, $result->steps[1]->status); self::assertSame([], $api->merges); } public function testDocsFirstFatalRefusesToMergeAnything(): void { $api = new FakePullRequestApi(); - $api->setSnapshot(self::INFRA_REPO, self::INFRA_BRANCH, $this->open(101, 'sha-infra')); $api->setSnapshot(self::CONDUCTOR_REPO, self::CONDUCTOR_BRANCH, $this->openConductor()); $api->setSnapshot(self::DOCS_REPO, self::DOCS_BRANCH, $this->merged(303, 'sha-docs')); @@ -206,10 +169,8 @@ public function testDocsFirstFatalRefusesToMergeAnything(): void public function testDryRunDoesNotMergeOrPostStatuses(): void { $api = new FakePullRequestApi(); - $api->setSnapshot(self::INFRA_REPO, self::INFRA_BRANCH, $this->open(101, 'sha-infra')); $api->setSnapshot(self::CONDUCTOR_REPO, self::CONDUCTOR_BRANCH, $this->openConductor()); $api->setSnapshot(self::DOCS_REPO, self::DOCS_BRANCH, $this->open(303, 'sha-docs')); - $api->setReadiness(self::INFRA_REPO, 101, $this->ready('sha-infra')); $api->setReadiness(self::CONDUCTOR_REPO, 202, $this->ready('sha-conductor')); $api->setReadiness(self::DOCS_REPO, 303, $this->ready('sha-docs')); @@ -226,10 +187,8 @@ public function testDryRunDoesNotMergeOrPostStatuses(): void public function testConductorAlreadyMergedRefetchesDocsBeforeMerging(): void { $api = new FakePullRequestApi(); - $api->setSnapshot(self::INFRA_REPO, self::INFRA_BRANCH, $this->open(101, 'sha-infra')); $api->setSnapshot(self::CONDUCTOR_REPO, self::CONDUCTOR_BRANCH, $this->mergedConductor()); $api->setSnapshot(self::DOCS_REPO, self::DOCS_BRANCH, $this->open(303, 'sha-docs-stale')); - $api->setReadiness(self::INFRA_REPO, 101, $this->ready('sha-infra')); $api->setReadinessSequence(self::DOCS_REPO, 303, [ $this->ready('sha-docs-stale'), $this->ready('sha-docs-fresh'), @@ -244,19 +203,16 @@ public function testConductorAlreadyMergedRefetchesDocsBeforeMerging(): void $result = (new ShipReleaseOrchestrator($api, new FakeClock()))->ship($this->targets()); self::assertTrue($result->wasSuccessful()); - self::assertSame(ShipReleaseStepStatus::MERGED, $result->steps[0]->status); - self::assertSame(ShipReleaseStepStatus::SKIPPED_ALREADY_MERGED, $result->steps[1]->status); - self::assertSame(ShipReleaseStepStatus::MERGED, $result->steps[2]->status); - self::assertSame('sha-docs-fresh', $api->merges[1]['expected']); + self::assertSame(ShipReleaseStepStatus::SKIPPED_ALREADY_MERGED, $result->steps[0]->status); + self::assertSame(ShipReleaseStepStatus::MERGED, $result->steps[1]->status); + self::assertSame('sha-docs-fresh', $api->merges[0]['expected']); } public function testConductorAlreadyMergedBlocksDocsIfDownstreamStillInFlight(): void { $api = new FakePullRequestApi(); - $api->setSnapshot(self::INFRA_REPO, self::INFRA_BRANCH, $this->open(101, 'sha-infra')); $api->setSnapshot(self::CONDUCTOR_REPO, self::CONDUCTOR_BRANCH, $this->mergedConductor()); $api->setSnapshot(self::DOCS_REPO, self::DOCS_BRANCH, $this->open(303, 'sha-docs')); - $api->setReadiness(self::INFRA_REPO, 101, $this->ready('sha-infra')); $api->setReadinessSequence(self::DOCS_REPO, 303, [ $this->ready('sha-docs'), new PullRequestReadiness('sha-docs', ['check core-test status=IN_PROGRESS']), @@ -265,11 +221,10 @@ public function testConductorAlreadyMergedBlocksDocsIfDownstreamStillInFlight(): $result = (new ShipReleaseOrchestrator($api, new FakeClock()))->ship($this->targets()); self::assertFalse($result->wasSuccessful()); - self::assertSame(ShipReleaseStepStatus::MERGED, $result->steps[0]->status); - self::assertSame(ShipReleaseStepStatus::SKIPPED_ALREADY_MERGED, $result->steps[1]->status); - self::assertSame(ShipReleaseStepStatus::BLOCKED, $result->steps[2]->status); - self::assertContains('check core-test status=IN_PROGRESS', $result->steps[2]->reasons); - self::assertCount(1, $api->merges); + self::assertSame(ShipReleaseStepStatus::SKIPPED_ALREADY_MERGED, $result->steps[0]->status); + self::assertSame(ShipReleaseStepStatus::BLOCKED, $result->steps[1]->status); + self::assertContains('check core-test status=IN_PROGRESS', $result->steps[1]->reasons); + self::assertSame([], $api->merges); } public function testDownstreamWaitTimeoutBlocksDocsMerge(): void @@ -279,10 +234,8 @@ public function testDownstreamWaitTimeoutBlocksDocsMerge(): void // Even if readiness still looks "clean", we must NOT merge stale // docs content (DRAFT→FINAL flip never happened). $api = new FakePullRequestApi(); - $api->setSnapshot(self::INFRA_REPO, self::INFRA_BRANCH, $this->open(101, 'sha-infra')); $api->setSnapshot(self::CONDUCTOR_REPO, self::CONDUCTOR_BRANCH, $this->openConductor()); $api->setSnapshot(self::DOCS_REPO, self::DOCS_BRANCH, $this->open(303, 'sha-docs')); - $api->setReadiness(self::INFRA_REPO, 101, $this->ready('sha-infra')); $api->setReadiness(self::CONDUCTOR_REPO, 202, $this->ready('sha-conductor')); $api->setReadiness(self::DOCS_REPO, 303, $this->ready('sha-docs')); @@ -292,11 +245,10 @@ public function testDownstreamWaitTimeoutBlocksDocsMerge(): void self::assertFalse($result->wasSuccessful()); self::assertGreaterThanOrEqual(30, $clock->totalSlept); self::assertSame(ShipReleaseStepStatus::MERGED, $result->steps[0]->status); - self::assertSame(ShipReleaseStepStatus::MERGED, $result->steps[1]->status); - self::assertSame(ShipReleaseStepStatus::BLOCKED, $result->steps[2]->status); - self::assertStringContainsString('did not change after conductor merge', $result->steps[2]->reasons[0]); - // Only infra + conductor merged; docs not. - self::assertCount(2, $api->merges); + self::assertSame(ShipReleaseStepStatus::BLOCKED, $result->steps[1]->status); + self::assertStringContainsString('did not change after conductor merge', $result->steps[1]->reasons[0]); + // Only conductor merged; docs not. + self::assertCount(1, $api->merges); } public function testWrongBaseBranchBlocksWithoutMerging(): void @@ -304,26 +256,23 @@ public function testWrongBaseBranchBlocksWithoutMerging(): void // Conductor PR exists but has been opened against `master` instead of // the expected `rel-810`. Refuse to merge it (would ship the wrong content). $api = new FakePullRequestApi(); - $api->setSnapshot(self::INFRA_REPO, self::INFRA_BRANCH, $this->open(101, 'sha-infra')); $api->setSnapshot(self::CONDUCTOR_REPO, self::CONDUCTOR_BRANCH, $this->open(202, 'sha-conductor', 'master')); $api->setSnapshot(self::DOCS_REPO, self::DOCS_BRANCH, $this->open(303, 'sha-docs')); - $api->setReadiness(self::INFRA_REPO, 101, $this->ready('sha-infra')); $api->setReadiness(self::DOCS_REPO, 303, $this->ready('sha-docs')); $result = (new ShipReleaseOrchestrator($api, new FakeClock()))->ship($this->targets()); self::assertFalse($result->wasSuccessful()); - self::assertSame(ShipReleaseStepStatus::BLOCKED, $result->steps[1]->status); - self::assertStringContainsString('PR base is master, expected rel-810', $result->steps[1]->reasons[0]); + self::assertSame(ShipReleaseStepStatus::BLOCKED, $result->steps[0]->status); + self::assertStringContainsString('PR base is master, expected rel-810', $result->steps[0]->reasons[0]); self::assertSame([], $api->merges); } public function testTargetsAreSortedByMergeOrderRegardlessOfInputOrder(): void { - // Pass targets in shuffled order; the orchestrator must still merge - // infra → conductor → docs. + // Pass targets in reverse order; the orchestrator must still merge + // conductor → docs. $api = new FakePullRequestApi(); - $api->setSnapshot(self::INFRA_REPO, self::INFRA_BRANCH, $this->open(101, 'sha-infra')); $api->setSnapshot(self::CONDUCTOR_REPO, self::CONDUCTOR_BRANCH, $this->openConductor()); $api->setSnapshot(self::DOCS_REPO, self::DOCS_BRANCH, $this->open(303, 'sha-docs-old')); $api->setSnapshotAfterFinds( @@ -332,18 +281,17 @@ public function testTargetsAreSortedByMergeOrderRegardlessOfInputOrder(): void 2, $this->open(303, 'sha-docs-new'), ); - $api->setReadiness(self::INFRA_REPO, 101, $this->ready('sha-infra')); $api->setReadiness(self::CONDUCTOR_REPO, 202, $this->ready('sha-conductor')); $api->setReadiness(self::DOCS_REPO, 303, $this->ready('sha-docs-new')); $shuffled = $this->targets(); - $shuffled = [$shuffled[2], $shuffled[0], $shuffled[1]]; // docs, infra, conductor + $shuffled = [$shuffled[1], $shuffled[0]]; // docs, conductor $result = (new ShipReleaseOrchestrator($api, new FakeClock()))->ship($shuffled); self::assertTrue($result->wasSuccessful()); self::assertSame( - [self::INFRA_REPO, self::CONDUCTOR_REPO, self::DOCS_REPO], + [self::CONDUCTOR_REPO, self::DOCS_REPO], array_column($api->merges, 'repo'), ); } @@ -351,23 +299,20 @@ public function testTargetsAreSortedByMergeOrderRegardlessOfInputOrder(): void public function testMergeApiFailureReportsBlockedAndStopsSubsequentMerges(): void { // Simulate gh failing on the conductor merge (e.g. --match-head-commit - // mismatch from a race). Infra still merges, conductor reports BLOCKED - // with the gh error, docs is NOT_REACHED. + // mismatch from a race). Conductor reports BLOCKED with the gh error, + // docs is NOT_REACHED. $api = new FailingMergeApi('openemr/openemr', 202, 'gh: --match-head-commit does not match'); - $api->setSnapshot(self::INFRA_REPO, self::INFRA_BRANCH, $this->open(101, 'sha-infra')); $api->setSnapshot(self::CONDUCTOR_REPO, self::CONDUCTOR_BRANCH, $this->openConductor()); $api->setSnapshot(self::DOCS_REPO, self::DOCS_BRANCH, $this->open(303, 'sha-docs')); - $api->setReadiness(self::INFRA_REPO, 101, $this->ready('sha-infra')); $api->setReadiness(self::CONDUCTOR_REPO, 202, $this->ready('sha-conductor')); $api->setReadiness(self::DOCS_REPO, 303, $this->ready('sha-docs')); $result = (new ShipReleaseOrchestrator($api, new FakeClock()))->ship($this->targets()); self::assertFalse($result->wasSuccessful()); - self::assertSame(ShipReleaseStepStatus::MERGED, $result->steps[0]->status); - self::assertSame(ShipReleaseStepStatus::BLOCKED, $result->steps[1]->status); - self::assertStringContainsString('--match-head-commit does not match', $result->steps[1]->reasons[0]); - self::assertSame(ShipReleaseStepStatus::NOT_REACHED, $result->steps[2]->status); + self::assertSame(ShipReleaseStepStatus::BLOCKED, $result->steps[0]->status); + self::assertStringContainsString('--match-head-commit does not match', $result->steps[0]->reasons[0]); + self::assertSame(ShipReleaseStepStatus::NOT_REACHED, $result->steps[1]->status); } public function testClosedWithoutMergingPrBlocks(): void @@ -375,10 +320,8 @@ public function testClosedWithoutMergingPrBlocks(): void // A PR that was closed without merging (state=CLOSED, mergedAt=null) // must not be treated as "open and ready to merge". $api = new FakePullRequestApi(); - $api->setSnapshot(self::INFRA_REPO, self::INFRA_BRANCH, $this->closed(101, 'sha-infra')); - $api->setSnapshot(self::CONDUCTOR_REPO, self::CONDUCTOR_BRANCH, $this->openConductor()); + $api->setSnapshot(self::CONDUCTOR_REPO, self::CONDUCTOR_BRANCH, $this->closed(202, 'sha-conductor', self::CONDUCTOR_BASE)); $api->setSnapshot(self::DOCS_REPO, self::DOCS_BRANCH, $this->open(303, 'sha-docs')); - $api->setReadiness(self::CONDUCTOR_REPO, 202, $this->ready('sha-conductor')); $api->setReadiness(self::DOCS_REPO, 303, $this->ready('sha-docs')); $result = (new ShipReleaseOrchestrator($api, new FakeClock()))->ship($this->targets()); @@ -387,7 +330,6 @@ public function testClosedWithoutMergingPrBlocks(): void self::assertSame(ShipReleaseStepStatus::BLOCKED, $result->steps[0]->status); self::assertStringContainsString('CLOSED without being merged', $result->steps[0]->reasons[0]); self::assertSame(ShipReleaseStepStatus::NOT_REACHED, $result->steps[1]->status); - self::assertSame(ShipReleaseStepStatus::NOT_REACHED, $result->steps[2]->status); self::assertSame([], $api->merges); } @@ -395,9 +337,8 @@ public function testDuplicateMergeOrderThrowsLogicException(): void { $api = new FakePullRequestApi(); $targets = [ - new PullRequestTarget('a/x', 'b1', 'master', RoleLabel::Infra, 1), - new PullRequestTarget('a/y', 'b2', 'master', RoleLabel::Conductor, 1), - new PullRequestTarget('a/z', 'b3', 'master', RoleLabel::Docs, 2), + new PullRequestTarget('a/x', 'b1', 'master', RoleLabel::Conductor, 1), + new PullRequestTarget('a/y', 'b2', 'master', RoleLabel::Docs, 1), ]; $this->expectException(\LogicException::class); @@ -411,10 +352,8 @@ public function testMergeFailureRetractsApprovalStatus(): void // same head SHA so a stale release/ship-approved=success doesn't // remain on the PR for branch protection to honor. $api = new FailingMergeApi('openemr/openemr', 202, 'gh: api error'); - $api->setSnapshot(self::INFRA_REPO, self::INFRA_BRANCH, $this->open(101, 'sha-infra')); $api->setSnapshot(self::CONDUCTOR_REPO, self::CONDUCTOR_BRANCH, $this->openConductor()); $api->setSnapshot(self::DOCS_REPO, self::DOCS_BRANCH, $this->open(303, 'sha-docs')); - $api->setReadiness(self::INFRA_REPO, 101, $this->ready('sha-infra')); $api->setReadiness(self::CONDUCTOR_REPO, 202, $this->ready('sha-conductor')); $api->setReadiness(self::DOCS_REPO, 303, $this->ready('sha-docs')); diff --git a/tools/release/tests/ShipReleaseSummaryRendererTest.php b/tools/release/tests/ShipReleaseSummaryRendererTest.php index afe55323..f0d58350 100644 --- a/tools/release/tests/ShipReleaseSummaryRendererTest.php +++ b/tools/release/tests/ShipReleaseSummaryRendererTest.php @@ -25,7 +25,6 @@ final class ShipReleaseSummaryRendererTest extends TestCase public function testRendersHeaderModeAndResult(): void { $result = new ShipReleaseResult([ - $this->step(RoleLabel::Infra, 'openemr/openemr-devops', ShipReleaseStepStatus::MERGED, 11, 'abc1234'), $this->step(RoleLabel::Conductor, 'openemr/openemr', ShipReleaseStepStatus::MERGED, 22, 'def5678'), $this->step(RoleLabel::Docs, 'openemr/website-openemr', ShipReleaseStepStatus::MERGED, 33, 'aaa9999'), ]); @@ -37,8 +36,8 @@ public function testRendersHeaderModeAndResult(): void self::assertStringContainsString('- **Result:** ✅ success', $md); self::assertStringContainsString('| Role | Repo | PR | Status | Detail |', $md); self::assertStringContainsString( - '| infra | `openemr/openemr-devops` ' - . '| [#11](https://github.com/openemr/openemr-devops/pull/11) | ✅ merged | `abc1234` |', + '| conductor | `openemr/openemr` ' + . '| [#22](https://github.com/openemr/openemr/pull/22) | ✅ merged | `def5678` |', $md, ); } @@ -46,15 +45,15 @@ public function testRendersHeaderModeAndResult(): void public function testDryRunModeAndWouldMerge(): void { $result = new ShipReleaseResult([ - $this->step(RoleLabel::Infra, 'openemr/openemr-devops', ShipReleaseStepStatus::WOULD_MERGE, 11, null), + $this->step(RoleLabel::Conductor, 'openemr/openemr', ShipReleaseStepStatus::WOULD_MERGE, 22, null), ]); $md = ShipReleaseSummaryRenderer::render('8.1.0', 'rel-810', true, $result); self::assertStringContainsString('- **Mode:** dry run (no merges performed)', $md); self::assertStringContainsString( - '| infra | `openemr/openemr-devops` ' - . '| [#11](https://github.com/openemr/openemr-devops/pull/11) | ✅ would merge | — |', + '| conductor | `openemr/openemr` ' + . '| [#22](https://github.com/openemr/openemr/pull/22) | ✅ would merge | — |', $md, ); } @@ -105,7 +104,7 @@ private function step( array $reasons = [], ): ShipReleaseStepResult { return new ShipReleaseStepResult( - new PullRequestTarget($repo, 'branch', 'master', $role, $role->value === 'infra' ? 1 : 2), + new PullRequestTarget($repo, 'branch', 'master', $role, $role->value === 'conductor' ? 1 : 2), $status, $prNumber, $mergeSha, diff --git a/tools/release/tests/SlotAssignmentParserTest.php b/tools/release/tests/SlotAssignmentParserTest.php deleted file mode 100644 index 9e850714..00000000 --- a/tools/release/tests/SlotAssignmentParserTest.php +++ /dev/null @@ -1,158 +0,0 @@ - - * @copyright Copyright (c) 2026 OpenCoreEMR Inc. - * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 - */ - -declare(strict_types=1); - -namespace OpenEMR\Release\Tests; - -use OpenEMR\Release\SlotAssignmentParser; -use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\TestCase; - -final class SlotAssignmentParserTest extends TestCase -{ - public function testRelCutDispatchAdvancesNextSlot(): void - { - $envelope = $this->loadFixture('good-rel-cut.json'); - - $assignments = (new SlotAssignmentParser())->fromDispatchPayload('openemr-rel-cut', $envelope); - - self::assertSame(['next' => '8.1'], $assignments); - } - - public function testRelUpdateDispatchAdvancesNextSlot(): void - { - $envelope = $this->loadFixture('good-rel-update.json'); - - $assignments = (new SlotAssignmentParser())->fromDispatchPayload('openemr-rel-update', $envelope); - - self::assertSame(['next' => '8.1'], $assignments); - } - - public function testTagDispatchPromotesCurrentSlot(): void - { - $envelope = $this->loadFixture('good-tag.json'); - - $assignments = (new SlotAssignmentParser())->fromDispatchPayload('openemr-tag', $envelope); - - self::assertSame(['current' => '8.1'], $assignments); - } - - public function testDocsBinariesDispatchYieldsNoSlotMove(): void - { - $envelope = $this->loadFixture('good-docs-binaries.json'); - - $assignments = (new SlotAssignmentParser())->fromDispatchPayload('openemr-docs-binaries', $envelope); - - self::assertSame([], $assignments); - } - - public function testMissingDataObjectThrows(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessageMatches("/missing 'data' object/"); - - (new SlotAssignmentParser())->fromDispatchPayload('openemr-tag', ['event' => 'openemr-tag']); - } - - public function testMissingVersionThrows(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/data\.version/'); - - (new SlotAssignmentParser())->fromDispatchPayload('openemr-rel-cut', ['data' => ['branch' => 'rel-810']]); - } - - public function testMalformedVersionThrows(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/MAJOR\.MINOR\.PATCH/'); - - (new SlotAssignmentParser())->fromDispatchPayload('openemr-tag', ['data' => ['version' => '8.1']]); - } - - public function testUnsupportedEventTypeThrows(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/Unsupported dispatch event/'); - - (new SlotAssignmentParser())->fromDispatchPayload( - 'openemr-mystery-event', - ['data' => ['version' => '8.1.0']], - ); - } - - /** - * @param array $expected - */ - #[DataProvider('parseProvider')] - public function testParseHandlesCliShorthandForms(string $slot, string $raw, array $expected): void - { - self::assertSame($expected, (new SlotAssignmentParser())->parse($slot, $raw)); - } - - /** - * @return array}> - */ - public static function parseProvider(): array - { - return [ - 'minor expands to slot tuple' => [ - 'next', - '8.2', - ['minor' => '8.2', 'full' => '8.2.0', 'branch' => 'rel-820', 'docker_dir' => '8.2.0'], - ], - 'edge maps to master branch' => ['dev', 'edge', ['branch' => 'master']], - 'key=value overrides apply verbatim' => [ - 'current', - 'minor=8.0,full=8.0.0,patch=8.0.0.3,branch=rel-800,docker_dir=8.0.0', - [ - 'minor' => '8.0', - 'full' => '8.0.0', - 'patch' => '8.0.0.3', - 'branch' => 'rel-800', - 'docker_dir' => '8.0.0', - ], - ], - ]; - } - - public function testParseRejectsBadInput(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessageMatches("/expected MAJOR\.MINOR or 'edge'/"); - - (new SlotAssignmentParser())->parse('next', 'banana'); - } - - /** - * @return array - */ - private function loadFixture(string $name): array - { - $path = __DIR__ . '/fixtures/dispatch/' . $name; - $raw = file_get_contents($path); - if ($raw === false) { - throw new \RuntimeException("Fixture not readable: {$path}"); - } - $decoded = json_decode($raw, true); - if (!is_array($decoded)) { - throw new \RuntimeException("Fixture not a JSON object: {$path}"); - } - $normalized = []; - foreach ($decoded as $key => $value) { - if (!is_string($key)) { - throw new \RuntimeException("Fixture has non-string root key: {$path}"); - } - $normalized[$key] = $value; - } - return $normalized; - } -} diff --git a/tools/release/tests/SlotRotatorTest.php b/tools/release/tests/SlotRotatorTest.php deleted file mode 100644 index e433a4a5..00000000 --- a/tools/release/tests/SlotRotatorTest.php +++ /dev/null @@ -1,417 +0,0 @@ - - * @copyright Copyright (c) 2026 OpenCoreEMR Inc. - * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 - */ - -declare(strict_types=1); - -namespace OpenEMR\Release\Tests; - -use OpenEMR\Release\SlotRotator; -use PHPUnit\Framework\TestCase; - -final class SlotRotatorTest extends TestCase -{ - private string $tmpDir = ''; - private string $registryPath = ''; - - protected function setUp(): void - { - $this->tmpDir = sys_get_temp_dir() . '/openemr-slot-rotator-' . bin2hex(random_bytes(8)); - if (!mkdir($this->tmpDir, 0700, true)) { - throw new \RuntimeException('Failed to create tmp dir: ' . $this->tmpDir); - } - $this->seedFixtures(); - $this->registryPath = $this->tmpDir . '/tools/release/versions.yml'; - } - - protected function tearDown(): void - { - $this->removeRecursive($this->tmpDir); - } - - public function testRotationAdvancesNextSlotAndRewritesPinFiles(): void - { - $rotator = new SlotRotator($this->tmpDir, $this->registryPath); - - $result = $rotator->rotate([ - 'next' => [ - 'minor' => '8.2', - 'full' => '8.2.0', - 'branch' => 'rel-820', - 'docker_dir' => '8.2.0', - ], - ]); - - self::assertFalse($result->isNoOp(), 'Rotation should have changed files'); - self::assertContains('docker/openemr/8.1.0/Dockerfile', $result->changedFiles); - self::assertContains('docker/openemr/next', $result->changedFiles); - self::assertContains('docker/openemr/OVERVIEW.md', $result->changedFiles); - self::assertContains('tools/release/versions.yml', $result->changedFiles); - - $dockerfile = (string) file_get_contents($this->tmpDir . '/docker/openemr/8.1.0/Dockerfile'); - self::assertStringContainsString('ARG OPENEMR_VERSION=rel-820', $dockerfile); - self::assertStringNotContainsString('rel-810', $dockerfile); - - self::assertSame( - '8.2.0', - readlink($this->tmpDir . '/docker/openemr/next'), - 'next symlink follows the slot docker_dir', - ); - - $registry = (string) file_get_contents($this->registryPath); - self::assertStringContainsString('full: "8.2.0"', $registry); - self::assertStringContainsString('branch: "rel-820"', $registry); - } - - public function testRotationRepointsSlotSymlinkAndLeavesOtherSlotsAlone(): void - { - $rotator = new SlotRotator($this->tmpDir, $this->registryPath); - - $result = $rotator->rotate([ - 'next' => [ - 'minor' => '8.2', - 'full' => '8.2.0', - 'branch' => 'rel-820', - 'docker_dir' => '8.2.0', - ], - ]); - - self::assertContains('docker/openemr/next', $result->changedFiles); - self::assertSame( - ['before' => '8.1.0', 'after' => '8.2.0'], - $result->snapshots['docker/openemr/next'], - ); - - $next = $this->tmpDir . '/docker/openemr/next'; - self::assertTrue(is_link($next), 'next must remain a symlink, not a regular file'); - self::assertSame('8.2.0', readlink($next)); - - self::assertSame('8.0.0', readlink($this->tmpDir . '/docker/openemr/current')); - self::assertSame('8.1.1', readlink($this->tmpDir . '/docker/openemr/dev')); - } - - public function testSymlinkRepointIsIdempotent(): void - { - $rotator = new SlotRotator($this->tmpDir, $this->registryPath); - $newNext = [ - 'minor' => '8.2', - 'full' => '8.2.0', - 'branch' => 'rel-820', - 'docker_dir' => '8.2.0', - ]; - - $rotator->rotate(['next' => $newNext]); - $second = $rotator->rotate(['next' => $newNext]); - - self::assertTrue($second->isNoOp(), 'Re-running with the same target must not touch the symlink'); - self::assertSame('8.2.0', readlink($this->tmpDir . '/docker/openemr/next')); - } - - public function testSymlinkUntouchedWhenDockerDirUnchanged(): void - { - $rotator = new SlotRotator($this->tmpDir, $this->registryPath); - - // Move only the branch (an `edge`-style override); docker_dir stays put. - $result = $rotator->rotate([ - 'dev' => ['branch' => 'rel-820'], - ]); - - self::assertNotContains('docker/openemr/dev', $result->changedFiles); - self::assertSame('8.1.1', readlink($this->tmpDir . '/docker/openemr/dev')); - } - - public function testDryRunDoesNotRepointSymlink(): void - { - $rotator = new SlotRotator($this->tmpDir, $this->registryPath); - - $result = $rotator->rotate( - [ - 'next' => [ - 'minor' => '8.2', - 'full' => '8.2.0', - 'branch' => 'rel-820', - 'docker_dir' => '8.2.0', - ], - ], - true, - ); - - self::assertArrayHasKey('docker/openemr/next', $result->snapshots); - self::assertSame( - '8.1.0', - readlink($this->tmpDir . '/docker/openemr/next'), - 'dry-run must not move the symlink', - ); - } - - public function testRotationIsIdempotent(): void - { - $rotator = new SlotRotator($this->tmpDir, $this->registryPath); - $newNext = [ - 'minor' => '8.2', - 'full' => '8.2.0', - 'branch' => 'rel-820', - 'docker_dir' => '8.2.0', - ]; - - $first = $rotator->rotate(['next' => $newNext]); - self::assertFalse($first->isNoOp(), 'First rotation should have changed files'); - - $second = $rotator->rotate(['next' => $newNext]); - self::assertTrue($second->isNoOp(), 'Second rotation with same args should be a no-op'); - self::assertSame([], $second->changedFiles); - } - - public function testNoOpDispatchAtCurrentSlotValuesChangesNothing(): void - { - $rotator = new SlotRotator($this->tmpDir, $this->registryPath); - - $result = $rotator->rotate([ - 'next' => [ - 'minor' => '8.1', - 'full' => '8.1.0', - 'branch' => 'rel-810', - 'docker_dir' => '8.1.0', - ], - ]); - - self::assertTrue($result->isNoOp(), 'Dispatching with the existing slot values must be a no-op'); - } - - public function testForwardRotationDoesNotCorruptOtherSlots(): void - { - $rotator = new SlotRotator($this->tmpDir, $this->registryPath); - - $rotator->rotate([ - 'next' => [ - 'minor' => '8.2', - 'full' => '8.2.0', - 'branch' => 'rel-820', - 'docker_dir' => '8.2.0', - ], - ]); - - $currentPath = $this->tmpDir . '/docker/openemr/8.0.0/Dockerfile'; - $current = (string) file_get_contents($currentPath); - self::assertStringContainsString('--branch rel-800', $current, 'current slot Dockerfile must not change'); - - $devPath = $this->tmpDir . '/docker/openemr/8.1.1/Dockerfile'; - $dev = (string) file_get_contents($devPath); - self::assertStringContainsString('ARG OPENEMR_VERSION=master', $dev, 'dev slot Dockerfile must not change'); - } - - public function testDryRunReturnsDiffWithoutWritingFiles(): void - { - $rotator = new SlotRotator($this->tmpDir, $this->registryPath); - $before = (string) file_get_contents($this->tmpDir . '/docker/openemr/8.1.0/Dockerfile'); - - $result = $rotator->rotate( - [ - 'next' => [ - 'minor' => '8.2', - 'full' => '8.2.0', - 'branch' => 'rel-820', - 'docker_dir' => '8.2.0', - ], - ], - true, - ); - - self::assertFalse($result->isNoOp()); - $after = (string) file_get_contents($this->tmpDir . '/docker/openemr/8.1.0/Dockerfile'); - self::assertSame($before, $after, 'dry-run must not touch the file'); - self::assertArrayHasKey('docker/openemr/8.1.0/Dockerfile', $result->snapshots); - } - - public function testUnknownSlotThrows(): void - { - $rotator = new SlotRotator($this->tmpDir, $this->registryPath); - - $this->expectException(\InvalidArgumentException::class); - $rotator->rotate(['nightly' => ['minor' => '9.0']]); - } - - public function testMissingPinFileThrows(): void - { - unlink($this->tmpDir . '/docker/openemr/8.1.0/Dockerfile'); - $rotator = new SlotRotator($this->tmpDir, $this->registryPath); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessageMatches('/Registry references missing file/'); - - $rotator->rotate([ - 'next' => [ - 'minor' => '8.2', - 'full' => '8.2.0', - 'branch' => 'rel-820', - 'docker_dir' => '8.2.0', - ], - ]); - } - - public function testReplacementAvoidsPartialVersionMatches(): void - { - file_put_contents( - $this->tmpDir . '/docker/openemr/8.1.0/Dockerfile', - "FROM alpine:3.21\nARG OPENEMR_VERSION=rel-810\n# version-like-but-not: 8.1.10 should stay\n", - ); - $rotator = new SlotRotator($this->tmpDir, $this->registryPath); - - $rotator->rotate([ - 'next' => [ - 'minor' => '8.2', - 'full' => '8.2.0', - 'branch' => 'rel-820', - 'docker_dir' => '8.2.0', - ], - ]); - - $after = (string) file_get_contents($this->tmpDir . '/docker/openemr/8.1.0/Dockerfile'); - self::assertStringContainsString('8.1.10', $after, '8.1 inside 8.1.10 must NOT be rewritten'); - self::assertStringContainsString('rel-820', $after); - } - - public function testRotationLeavesScriptdirShellcheckDirectiveIntact(): void - { - $rotator = new SlotRotator($this->tmpDir, $this->registryPath); - - $rotator->rotate([ - 'current' => [ - 'minor' => '8.2', - 'full' => '8.2.0', - 'branch' => 'rel-820', - 'docker_dir' => '8.2.0', - ], - ]); - - $script = (string) file_get_contents($this->tmpDir . '/docker/openemr/8.0.0/openemr.sh'); - self::assertStringContainsString( - '# shellcheck source=SCRIPTDIR/env.stub', - $script, - 'self-referential SCRIPTDIR directive must survive rotation byte-for-byte', - ); - self::assertStringNotContainsString( - 'source=docker/openemr', - $script, - 'rotation must never inject a version path into a shellcheck source directive', - ); - self::assertStringContainsString( - "echo 'init for docker/openemr/8.2.0'", - $script, - 'sanity: the genuine rotating docker_dir token should have been rewritten', - ); - } - - private function seedFixtures(): void - { - $this->writeFile('tools/release/versions.yml', <<<'YAML' - version: 1 - - slots: - current: - minor: "8.0" - full: "8.0.0" - branch: "rel-800" - docker_dir: "8.0.0" - next: - minor: "8.1" - full: "8.1.0" - branch: "rel-810" - docker_dir: "8.1.0" - dev: - minor: "8.1" - full: "8.1.1" - branch: "master" - docker_dir: "8.1.1" - - files: - - path: docker/openemr/8.0.0/Dockerfile - slot: current - kinds: [docker_clone_branch] - - path: docker/openemr/8.0.0/openemr.sh - slot: current - kinds: [docker_dir_ref] - - path: docker/openemr/8.1.0/Dockerfile - slot: next - kinds: [docker_arg_branch] - - path: docker/openemr/8.1.1/Dockerfile - slot: dev - kinds: [docker_arg_branch] - - path: docker/openemr/OVERVIEW.md - slot: all - kinds: [overview_table] - - excludes: [] - YAML); - - $this->writeFile( - 'docker/openemr/8.0.0/Dockerfile', - "FROM alpine:3.21\nRUN git clone https://github.com/openemr/openemr.git --branch rel-800 --depth 1\n", - ); - $this->writeFile('docker/openemr/8.1.0/Dockerfile', "FROM alpine:3.21\nARG OPENEMR_VERSION=rel-810\n"); - $this->writeFile('docker/openemr/8.1.1/Dockerfile', "FROM alpine:3.21\nARG OPENEMR_VERSION=master\n"); - - // In-container init script for the current slot. It carries a rotating - // docker_dir token (the path in the echo line) alongside a - // self-referential `SCRIPTDIR` shellcheck directive that must never be - // rewritten. - $this->writeFile( - 'docker/openemr/8.0.0/openemr.sh', - "#!/bin/sh\nset -e\n" - . "# shellcheck source=SCRIPTDIR/env.stub\n. /root/env.stub\n" - . "echo 'init for docker/openemr/8.0.0'\n", - ); - - $this->writeFile( - 'docker/openemr/OVERVIEW.md', - "| 8.0.0 | latest |\n| 8.1.0 | next |\n| 8.1.1 | dev |\n", - ); - - // Slot symlinks: the source of truth the consolidated build workflow - // resolves each slot's version from. Rotation re-points these (see - // SlotRotator::repointSlotSymlink). Relative targets, as in the repo. - symlink('8.0.0', $this->tmpDir . '/docker/openemr/current'); - symlink('8.1.0', $this->tmpDir . '/docker/openemr/next'); - symlink('8.1.1', $this->tmpDir . '/docker/openemr/dev'); - } - - private function writeFile(string $relPath, string $contents): void - { - $absPath = $this->tmpDir . '/' . $relPath; - $dir = dirname($absPath); - if (!is_dir($dir) && !mkdir($dir, 0700, true)) { - throw new \RuntimeException("Failed to mkdir: {$dir}"); - } - file_put_contents($absPath, $contents); - } - - private function removeRecursive(string $path): void - { - if (!is_dir($path)) { - if (is_file($path) || is_link($path)) { - unlink($path); - } - return; - } - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::CHILD_FIRST, - ); - /** @var \SplFileInfo $entry */ - foreach ($iterator as $entry) { - $entryPath = $entry->getPathname(); - if ($entry->isDir() && !$entry->isLink()) { - rmdir($entryPath); - } else { - unlink($entryPath); - } - } - rmdir($path); - } -} diff --git a/tools/release/tests/VersionsRegistryLinterTest.php b/tools/release/tests/VersionsRegistryLinterTest.php deleted file mode 100644 index 3b2677f3..00000000 --- a/tools/release/tests/VersionsRegistryLinterTest.php +++ /dev/null @@ -1,226 +0,0 @@ - - * @copyright Copyright (c) 2026 OpenCoreEMR Inc. - * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 - */ - -declare(strict_types=1); - -namespace OpenEMR\Release\Tests; - -use OpenEMR\Release\VersionsRegistryLinter; -use PHPUnit\Framework\TestCase; -use Symfony\Component\Process\Process; - -final class VersionsRegistryLinterTest extends TestCase -{ - private string $tmpDir = ''; - private string $registryPath = ''; - - protected function setUp(): void - { - $this->tmpDir = sys_get_temp_dir() . '/openemr-versions-linter-' . bin2hex(random_bytes(8)); - if (!mkdir($this->tmpDir, 0700, true)) { - throw new \RuntimeException('Failed to create tmp dir: ' . $this->tmpDir); - } - $this->git(['init', '-q', '-b', 'main']); - $this->seedBaseRegistry(); - $this->registryPath = $this->tmpDir . '/tools/release/versions.yml'; - } - - protected function tearDown(): void - { - $this->removeRecursive($this->tmpDir); - } - - public function testCleanRepoProducesNoIssues(): void - { - $this->writeFileAndAdd('docker/openemr/8.1.0/Dockerfile', "ARG OPENEMR_VERSION=rel-810\n"); - $this->updateRegistry([ - ['path' => 'docker/openemr/8.1.0/Dockerfile', 'slot' => 'next'], - ], []); - - $issues = (new VersionsRegistryLinter($this->tmpDir, $this->registryPath))->lint(); - - self::assertSame([], $issues); - } - - public function testUnregisteredFileWithImageTagIsFlagged(): void - { - $this->writeFileAndAdd( - '.github/workflows/build-new.yml', - "tags: openemr/openemr:8.3.0, openemr/openemr:next-next\n", - ); - - $issues = (new VersionsRegistryLinter($this->tmpDir, $this->registryPath))->lint(); - - self::assertNotEmpty($issues); - $paths = array_map(static fn(\OpenEMR\Release\LintIssue $i): string => $i->path, $issues); - self::assertContains('.github/workflows/build-new.yml', $paths); - } - - public function testUnregisteredFileWithBuildArgIsFlagged(): void - { - $this->writeFileAndAdd('docker/openemr/8.2.0/Dockerfile', "ARG OPENEMR_VERSION=rel-820\n"); - - $issues = (new VersionsRegistryLinter($this->tmpDir, $this->registryPath))->lint(); - - self::assertNotEmpty($issues); - $paths = array_unique(array_map(static fn(\OpenEMR\Release\LintIssue $i): string => $i->path, $issues)); - self::assertContains('docker/openemr/8.2.0/Dockerfile', $paths); - } - - public function testExcludedDirectoryIsSkipped(): void - { - $this->writeFileAndAdd('docker/obsolete/old/Dockerfile', "ARG OPENEMR_VERSION=rel-700\n"); - $this->updateRegistry([], [ - ['path' => 'docker/obsolete', 'reason' => 'frozen historical builds'], - ]); - - $issues = (new VersionsRegistryLinter($this->tmpDir, $this->registryPath))->lint(); - - self::assertSame([], $issues); - } - - public function testExcludedExactPathIsSkipped(): void - { - $this->writeFileAndAdd('packages/standard/cfn/stack.py', "docker_version = ':7.0.3'\n"); - $this->updateRegistry([], [ - ['path' => 'packages/standard/cfn/stack.py', 'reason' => 'independent cadence'], - ]); - - $issues = (new VersionsRegistryLinter($this->tmpDir, $this->registryPath))->lint(); - - self::assertSame([], $issues); - } - - public function testNonOpenemrVersionPinsAreNotFlagged(): void - { - $this->writeFileAndAdd( - '.github/workflows/build-new.yml', - " - uses: actions/setup-php@v3\n with:\n php-version: '8.4'\n", - ); - - $issues = (new VersionsRegistryLinter($this->tmpDir, $this->registryPath))->lint(); - - self::assertSame([], $issues, 'PHP/Alpine/etc. version pins must not trigger the linter'); - } - - public function testIssueRecordsLineNumberAndPatternKind(): void - { - $this->writeFileAndAdd( - 'docker/openemr/8.2.0/README.md', - "# Header\n\nimage: openemr/openemr:8.2.0\n", - ); - - $issues = (new VersionsRegistryLinter($this->tmpDir, $this->registryPath))->lint(); - - self::assertNotEmpty($issues); - $first = $issues[0]; - self::assertSame('docker/openemr/8.2.0/README.md', $first->path); - self::assertSame(3, $first->line); - self::assertSame('docker_image_tag', $first->patternKind); - self::assertSame('8.2.0', $first->matched); - } - - private function seedBaseRegistry(): void - { - $this->writeFileAndAdd('tools/release/versions.yml', <<<'YAML' - version: 1 - - slots: - current: { minor: "8.0", full: "8.0.0", branch: "rel-800", docker_dir: "8.0.0" } - next: { minor: "8.1", full: "8.1.0", branch: "rel-810", docker_dir: "8.1.0" } - dev: { minor: "8.1", full: "8.1.1", branch: "master", docker_dir: "8.1.1" } - - files: [] - - excludes: [] - YAML); - } - - /** - * @param list $files - * @param list $excludes - */ - private function updateRegistry(array $files, array $excludes): void - { - $yaml = "version: 1\n\nslots:\n" - . " current: { minor: \"8.0\", full: \"8.0.0\", branch: \"rel-800\", docker_dir: \"8.0.0\" }\n" - . " next: { minor: \"8.1\", full: \"8.1.0\", branch: \"rel-810\", docker_dir: \"8.1.0\" }\n" - . " dev: { minor: \"8.1\", full: \"8.1.1\", branch: \"master\", docker_dir: \"8.1.1\" }\n\n"; - - $yaml .= "files:\n"; - if ($files === []) { - $yaml .= " []\n"; - } else { - foreach ($files as $f) { - $yaml .= sprintf(" - { path: %s, slot: %s }\n", $f['path'], $f['slot']); - } - } - $yaml .= "\nexcludes:\n"; - if ($excludes === []) { - $yaml .= " []\n"; - } else { - foreach ($excludes as $e) { - $yaml .= sprintf(" - { path: %s, reason: \"%s\" }\n", $e['path'], $e['reason']); - } - } - file_put_contents($this->registryPath, $yaml); - } - - private function writeFileAndAdd(string $relPath, string $contents): void - { - $absPath = $this->tmpDir . '/' . $relPath; - $dir = dirname($absPath); - if (!is_dir($dir) && !mkdir($dir, 0700, true)) { - throw new \RuntimeException("Failed to mkdir: {$dir}"); - } - file_put_contents($absPath, $contents); - $this->git(['add', $relPath]); - } - - /** - * @param list $args - */ - private function git(array $args): void - { - $process = new Process(['git', ...$args], $this->tmpDir, [ - 'GIT_CONFIG_GLOBAL' => '/dev/null', - 'GIT_CONFIG_SYSTEM' => '/dev/null', - 'GIT_AUTHOR_NAME' => 'Test', - 'GIT_AUTHOR_EMAIL' => 'test@example.test', - 'GIT_COMMITTER_NAME' => 'Test', - 'GIT_COMMITTER_EMAIL' => 'test@example.test', - ]); - $process->mustRun(); - } - - private function removeRecursive(string $path): void - { - if (!is_dir($path)) { - if (is_file($path) || is_link($path)) { - unlink($path); - } - return; - } - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::CHILD_FIRST, - ); - /** @var \SplFileInfo $entry */ - foreach ($iterator as $entry) { - $entryPath = $entry->getPathname(); - if ($entry->isDir() && !$entry->isLink()) { - rmdir($entryPath); - } else { - unlink($entryPath); - } - } - rmdir($path); - } -} diff --git a/tools/release/versions.yml b/tools/release/versions.yml deleted file mode 100644 index 78937b8b..00000000 --- a/tools/release/versions.yml +++ /dev/null @@ -1,227 +0,0 @@ -# Release-tooling registry of OpenEMR version pins in this repo. -# -# Drives: -# - tools/release/bin/rotate.php (slot rotation) -# - tools/release/bin/lint-versions.php (drift detection) -# -# Rotation model (openemr/openemr-devops#664): -# current = most recent tagged release (Docker tag :latest) -# next = upcoming release / active rel-* branch (Docker tag :next) -# dev = head of master (Docker tag :dev) -# -# This file is the source of truth. Editing slot assignments below + running -# `task release:rotate` rewrites every pin listed in `files`. -# -# DRAFT: built by manual sweep on 2026-04-29. Several files were excluded with -# explicit reasons; ambiguous cases are reported in PR #665. - -version: 1 - -slots: - current: - minor: "8.0" - # "8.0.0.3" is legacy quaternary (4-part) versioning. 8.1.x onward uses - # plain semver. When current rotates off the 8.0.0.x line, this becomes a - # plain 3-part version. See #746. - full: "8.0.0.3" - branch: "rel-800" - docker_dir: "8.0.0" - next: - minor: "8.1" - full: "8.1.0" - branch: "rel-810" - docker_dir: "8.1.0" - dev: - minor: "8.1" - full: "8.1.1-dev" - branch: "master" - docker_dir: "8.1.1" - -# Files containing rotating OpenEMR version pins. The rotator dispatches on -# `kind` per pin to decide the rewrite strategy; the linter uses these entries -# to verify no version-looking string lives in a non-listed location. -# -# The build workflow is intentionally NOT listed: build-openemr.yml is a single -# slot-named matrix carrying zero version strings. It resolves each slot's -# version at runtime from the docker/openemr/{current,next,dev} symlinks, which -# rotation re-points (see tools/release/src/SlotRotator.php). Nothing to rewrite. -# -# `kind` taxonomy: -# docker_arg_branch — Dockerfile `ARG OPENEMR_VERSION=` -# docker_clone_branch — Dockerfile `git clone … --branch ` -# docker_image_tag — markdown/yaml `openemr/openemr:` reference -# bats_test_paths — workflow path filter `docker/openemr//**` -# container_func_test_paths — workflow path filter + script arg -# benchmarking_default — utilities/container_benchmarking default variant -files: - - path: docker/openemr/8.0.0/Dockerfile - slot: current - kinds: [docker_arg_branch] - - - path: docker/openemr/8.1.0/Dockerfile - slot: next - kinds: [docker_arg_branch] - - - path: docker/openemr/8.1.1/Dockerfile - slot: dev - kinds: [docker_arg_branch] - - - path: docker/openemr/8.0.0/README.md - slot: current - kinds: [docker_image_tag] - # FIXME: line 33 currently reads `image: openemr/openemr:7.0.5` — a stale - # copy-paste from before 8.0.0 was tagged. Rotator should overwrite to - # match slot.full on next run; flagged in PR #665 for human confirmation. - - - path: docker/openemr/8.1.0/README.md - slot: next - kinds: [docker_image_tag] - - - path: docker/openemr/8.1.1/README.md - slot: dev - kinds: [docker_image_tag] - - - path: .github/workflows/test-bats.yml - slot: next - kinds: [bats_test_paths] - - - path: .github/workflows/test-container-functionality.yml - slot: next - kinds: [container_func_test_paths] - - - path: utilities/container_benchmarking/docker-compose.yml - slot: next - kinds: [benchmarking_default] - - - path: utilities/container_benchmarking/benchmark.sh - slot: next - kinds: [benchmarking_default] - - - path: utilities/container_benchmarking/test_functionality.sh - slot: next - kinds: [benchmarking_default] - - - path: utilities/container_benchmarking/test_suite.sh - slot: next - kinds: [benchmarking_default] - - # Dependabot enumerates each rotating Docker dir explicitly; rotation needs - # to add/remove entries when slot dirs change. - - path: .github/dependabot.yml - slot: all - kinds: [dependabot_directory] - - # Container-benchmarking docs reference the next-slot version as defaults. - - path: utilities/container_benchmarking/README.md - slot: next - kinds: [docker_image_tag] - -# Files the linter would otherwise flag as containing OpenEMR version pins, but -# which intentionally don't participate in 3-slot rotation. Each entry needs a -# reason — that's the cost of bypassing the lint. -excludes: - # Legacy 7.0.4 support track — rotated by hand, not by slot system. - - path: .github/workflows/build-704.yml - reason: "legacy 7.0.4 support track; manual rotation only" - - path: docker/openemr/7.0.4 - reason: "legacy 7.0.4 support track (entire dir)" - - # Frozen historical builds. - - path: docker/obsolete - reason: "frozen historical builds; never rotate" - - path: kubernetes/obsolete - reason: "frozen historical builds; never rotate" - - # Binary track — own release cadence (currently 7_0_4 + dated build). - - path: docker/openemr/binary/Dockerfile - reason: "binary release track on independent cadence" - - path: docker/openemr/binary/README.md - reason: "binary release track on independent cadence" - - # Flex (Alpine) track — separate axis from OpenEMR version slots. - - path: .github/workflows/build-322.yml - reason: "Alpine flex track (3.22), not OpenEMR rotation" - - path: .github/workflows/build-323.yml - reason: "Alpine flex track (3.23, currently :flex), not OpenEMR rotation" - - path: .github/workflows/build-edge.yml - reason: "Alpine flex-edge track, not OpenEMR rotation" - - path: .github/workflows/build-flex-core.yml - reason: "reusable flex build, no fixed pin" - - path: .github/workflows/test-flex-322.yml - reason: "Alpine flex test" - - path: .github/workflows/test-flex-323.yml - reason: "Alpine flex test" - - path: .github/workflows/test-flex-edge.yml - reason: "Alpine flex-edge test" - - path: docker/openemr/flex/Dockerfile - reason: "flex variant fetches OpenEMR at runtime; no build-time pin" - - path: docker/openemr/flex/README.md - reason: "flex variant; tag references are doc-only" - - # Workflows with no fixed pin (parameterized or version-agnostic). - - path: .github/workflows/build-patch.yml - reason: "version comes from workflow_dispatch inputs" - - path: .github/workflows/build-release.yml - reason: "version comes from workflow_dispatch inputs" - - path: .github/workflows/test-core.yml - reason: "reusable workflow; callers supply version" - - path: .github/workflows/test-production.yml - reason: "pins production_coverage_openemr_version='7.0.5' (release not yet built); see PR #665" - - # AWS deployment packages — independent release cadence; lag the Docker - # slots and currently span three different OpenEMR versions across the four - # packages. Maintainer decision pending on whether to fold into the 3-slot - # system. See PR #665 "Ambiguous registry entries". - - path: packages/appliance/docker-compose.yml - reason: "appliance package (currently 7.0.4) — independent cadence; pending decision" - - path: packages/standard/cfn/stack.py - reason: "standard CFN (currently 7.0.3) — independent cadence; pending decision" - - path: packages/standard/cfn/OpenEMR-Standard.json - reason: "generated from standard/cfn/stack.py" - - path: packages/standard/cfn/OpenEMR-Standard-Recovery.json - reason: "generated from standard/cfn/stack.py" - - path: packages/standard/ami/ami-build.sh - reason: "AMI build (currently 7.0.3) — tracks standard/cfn/stack.py" - - path: packages/express_plus/stack.py - reason: "express_plus CFN — independent cadence; pending decision" - - path: packages/express_plus/OpenEMR-Express-Plus.json - reason: "generated from express_plus/stack.py" - - path: packages/express_plus/OpenEMR-Express-Plus-Recovery.json - reason: "generated from express_plus/stack.py" - - path: packages/express/README.md - reason: "doc-only OpenEMR version reference (currently 7.0.4)" - - path: packages/lightsail/launch.sh - reason: "stub redirecting to appliance; no OpenEMR pin" - - # K8s manifest currently lags the Docker rotation; needs a one-shot - # alignment commit before joining the rotation registry. - - path: kubernetes/openemr/deployment.yaml - reason: "pinned to 7.0.3; needs reconciliation before joining rotation" - - path: kubernetes/README.md - reason: "doc-only image-tag examples; not a deployment pin" - - # Documentation that mentions versions in prose only. - - path: docker/openemr/COVERAGE.md - reason: "prose mention of 7.0.4 as 'future production' — stale doc, not a pin" - - path: raspberrypi/README.md - reason: "documentation; references master branch only" - - # Upgrade machinery — version-looking strings are step indices and - # historical SQL-upgrade hints, not active pins. - - path: docker/openemr/8.0.0/upgrade - reason: "upgrade scripts: docker-version is a step index; fsupgrade-N.sh refs are historical" - - path: docker/openemr/8.1.0/upgrade - reason: "upgrade scripts: docker-version is a step index; fsupgrade-N.sh refs are historical" - - path: docker/openemr/8.1.1/upgrade - reason: "upgrade scripts: docker-version is a step index; fsupgrade-N.sh refs are historical" - - path: docker/openemr/7.0.4/upgrade - reason: "upgrade scripts: docker-version is a step index; fsupgrade-N.sh refs are historical" - - # Demo SQL fixture — filename matches version regex but isn't a pin. - - path: docker/openemr/8.0.0/demo_5_0_0_5.sql - reason: "demo SQL fixture; filename matches regex but isn't an OpenEMR pin" - - # Release tooling itself — versions appear in tests and documentation but - # are not subject to rotation. - - path: tools/release - reason: "release tooling; versions in tests and contracts, not pins" From a55843b5e764e985a127744358d01d920bd6f466 Mon Sep 17 00:00:00 2001 From: Brady Miller Date: Thu, 25 Jun 2026 08:27:15 +0000 Subject: [PATCH 2/4] fix(test): line-wrap a 131-char setSnapshot call in ShipReleaseOrchestratorTest phpcs Generic.Files.LineLength caught a 131-char line at ShipReleaseOrchestratorTest.php:323 (limit 120) introduced when rewriting the test from the 3-PR shape to 2-PR shape. Wraps the setSnapshot call across multiple lines to fit the 120-char limit. Assisted-by: Claude Code --- tools/release/tests/ShipReleaseOrchestratorTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/release/tests/ShipReleaseOrchestratorTest.php b/tools/release/tests/ShipReleaseOrchestratorTest.php index a304cdf3..f4c558c0 100644 --- a/tools/release/tests/ShipReleaseOrchestratorTest.php +++ b/tools/release/tests/ShipReleaseOrchestratorTest.php @@ -320,7 +320,11 @@ public function testClosedWithoutMergingPrBlocks(): void // A PR that was closed without merging (state=CLOSED, mergedAt=null) // must not be treated as "open and ready to merge". $api = new FakePullRequestApi(); - $api->setSnapshot(self::CONDUCTOR_REPO, self::CONDUCTOR_BRANCH, $this->closed(202, 'sha-conductor', self::CONDUCTOR_BASE)); + $api->setSnapshot( + self::CONDUCTOR_REPO, + self::CONDUCTOR_BRANCH, + $this->closed(202, 'sha-conductor', self::CONDUCTOR_BASE), + ); $api->setSnapshot(self::DOCS_REPO, self::DOCS_BRANCH, $this->open(303, 'sha-docs')); $api->setReadiness(self::DOCS_REPO, 303, $this->ready('sha-docs')); From 509afa33cefd5ea033f98e2309f9cb57a0e4deb6 Mon Sep 17 00:00:00 2001 From: Brady Miller Date: Sat, 27 Jun 2026 08:03:13 +0000 Subject: [PATCH 3/4] fix(release): enforce {Conductor, Docs} contract in sortByMergeOrder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses coderabbitai outside-diff review on PR 835. sortByMergeOrder() previously only rejected duplicate mergeOrder values. A stale caller could still pass 3 targets (the pre-collapse Infra+Conductor+Docs list), 1 target, or 2 targets of the wrong role set, and the orchestrator would proceed until much later — leading to a silent half-failure deep in the downstream merge logic. The 2-PR ship contract is exactly {Conductor, Docs} in any order. sortByMergeOrder() now fails fast on: - count(targets) != 2 — catches both pre-collapse 3-PR callers and single-target accidents - role set != {Conductor, Docs} — catches future role enum drift (e.g., a new Infra-like role added without updating PullRequestTarget::forRelease) - duplicate mergeOrder — existing check, retained Two new tests: - testWrongTargetCountThrowsLogicException — covers count != 2 - testWrongTargetRoleSetThrowsLogicException — covers wrong roles with correct count (two Conductors) Original duplicate-mergeOrder test renamed nothing — same fixture + assertion, both targets are still Conductor+Docs with mergeOrder 1+1. Assisted-by: Claude Code --- tools/release/src/ShipReleaseOrchestrator.php | 22 +++++++++++-- .../tests/ShipReleaseOrchestratorTest.php | 31 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/tools/release/src/ShipReleaseOrchestrator.php b/tools/release/src/ShipReleaseOrchestrator.php index b1bd85fe..2dd10c55 100644 --- a/tools/release/src/ShipReleaseOrchestrator.php +++ b/tools/release/src/ShipReleaseOrchestrator.php @@ -67,14 +67,32 @@ public function ship(array $targets): ShipReleaseResult } /** - * Defensive sort + uniqueness check so a caller passing targets in any - * order still gets conductor → docs at merge time. + * Defensive sort + contract check. The 2-PR ship contract requires + * exactly the Conductor and Docs targets in any order; this method + * fails fast on a stale caller passing the wrong shape (e.g., an + * extra target, a missing one, or duplicate mergeOrder values), + * since the alternative is a silent half-failure deep in the + * downstream merge logic. * * @param list $targets * @return list */ private function sortByMergeOrder(array $targets): array { + if (count($targets) !== 2) { + throw new \LogicException( + 'ship-release expects exactly 2 targets (Conductor + Docs); got ' . count($targets), + ); + } + $roles = array_map(static fn (PullRequestTarget $t): string => $t->roleLabel->value, $targets); + sort($roles); + $expected = [RoleLabel::Conductor->value, RoleLabel::Docs->value]; + sort($expected); + if ($roles !== $expected) { + throw new \LogicException( + 'ship-release targets must be {Conductor, Docs}; got {' . implode(', ', $roles) . '}', + ); + } $orders = array_map(static fn (PullRequestTarget $t): int => $t->mergeOrder, $targets); if (count(array_unique($orders)) !== count($orders)) { throw new \LogicException('ship-release targets have duplicate mergeOrder values'); diff --git a/tools/release/tests/ShipReleaseOrchestratorTest.php b/tools/release/tests/ShipReleaseOrchestratorTest.php index f4c558c0..92f138ce 100644 --- a/tools/release/tests/ShipReleaseOrchestratorTest.php +++ b/tools/release/tests/ShipReleaseOrchestratorTest.php @@ -350,6 +350,37 @@ public function testDuplicateMergeOrderThrowsLogicException(): void (new ShipReleaseOrchestrator($api, new FakeClock()))->ship($targets); } + public function testWrongTargetCountThrowsLogicException(): void + { + // Stale callers (e.g., still passing the pre-collapse 3-PR list, + // or a single Conductor-only target) must fail fast rather than + // silently half-shipping. + $api = new FakePullRequestApi(); + $targets = [ + new PullRequestTarget('a/x', 'b1', 'master', RoleLabel::Conductor, 1), + ]; + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('exactly 2 targets'); + (new ShipReleaseOrchestrator($api, new FakeClock()))->ship($targets); + } + + public function testWrongTargetRoleSetThrowsLogicException(): void + { + // Two targets of correct count but wrong roles (e.g., two + // Conductors, or a Conductor + something else) violates the + // {Conductor, Docs} contract and must fail fast. + $api = new FakePullRequestApi(); + $targets = [ + new PullRequestTarget('a/x', 'b1', 'master', RoleLabel::Conductor, 1), + new PullRequestTarget('a/y', 'b2', 'master', RoleLabel::Conductor, 2), + ]; + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('{Conductor, Docs}'); + (new ShipReleaseOrchestrator($api, new FakeClock()))->ship($targets); + } + public function testMergeFailureRetractsApprovalStatus(): void { // Verify the success status is followed by a failure status on the From a70e07935866c0b4c2e879dc8138a02b8ecc0686 Mon Sep 17 00:00:00 2001 From: Brady Miller Date: Sat, 27 Jun 2026 08:47:58 +0000 Subject: [PATCH 4/4] fix(release): enforce Conductor-before-Docs mergeOrder in sortByMergeOrder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses coderabbitai outside-diff finding on PR 835 (rabbit round 2). The prior role-set + dedup-mergeOrder checks miss a case: a stale caller could pass {Conductor: mergeOrder=2, Docs: mergeOrder=1}. That passes the new {Conductor, Docs} contract check + the duplicate-mergeOrder check, but usort then puts Docs first — silently violating the strict conductor → docs merge sequence ship-release.yml depends on. Adds an explicit check after the dedup-mergeOrder validation: Conductor.mergeOrder must be strictly less than Docs.mergeOrder. Throws a LogicException with both values in the error message when violated. New test: testReversedMergeOrderThrowsLogicException. Verifies the exception fires with the canonical {Conductor, Docs} role set but reversed mergeOrder values (Conductor=2, Docs=1). Assisted-by: Claude Code --- tools/release/src/ShipReleaseOrchestrator.php | 12 ++++++++++++ .../tests/ShipReleaseOrchestratorTest.php | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/tools/release/src/ShipReleaseOrchestrator.php b/tools/release/src/ShipReleaseOrchestrator.php index 2dd10c55..d0ada3a3 100644 --- a/tools/release/src/ShipReleaseOrchestrator.php +++ b/tools/release/src/ShipReleaseOrchestrator.php @@ -97,6 +97,18 @@ private function sortByMergeOrder(array $targets): array if (count(array_unique($orders)) !== count($orders)) { throw new \LogicException('ship-release targets have duplicate mergeOrder values'); } + // Enforce Conductor-before-Docs at the mergeOrder level too — + // {Conductor, Docs} with Docs.mergeOrder < Conductor.mergeOrder + // would pass the role-set + dedup checks above but usort would + // then merge Docs first, violating the strict merge sequence. + $conductor = $this->findRequired($targets, RoleLabel::Conductor); + $docs = $this->findRequired($targets, RoleLabel::Docs); + if ($conductor->mergeOrder >= $docs->mergeOrder) { + throw new \LogicException( + 'ship-release mergeOrder must put Conductor before Docs; got Conductor=' + . $conductor->mergeOrder . ' Docs=' . $docs->mergeOrder, + ); + } usort( $targets, static fn (PullRequestTarget $a, PullRequestTarget $b): int => $a->mergeOrder <=> $b->mergeOrder, diff --git a/tools/release/tests/ShipReleaseOrchestratorTest.php b/tools/release/tests/ShipReleaseOrchestratorTest.php index 92f138ce..8dd3ac16 100644 --- a/tools/release/tests/ShipReleaseOrchestratorTest.php +++ b/tools/release/tests/ShipReleaseOrchestratorTest.php @@ -381,6 +381,24 @@ public function testWrongTargetRoleSetThrowsLogicException(): void (new ShipReleaseOrchestrator($api, new FakeClock()))->ship($targets); } + public function testReversedMergeOrderThrowsLogicException(): void + { + // Correct role set {Conductor, Docs} with unique mergeOrder + // values, but Docs.mergeOrder < Conductor.mergeOrder. The + // role-set + dedup checks would pass, but usort would then put + // Docs first — silently violating the strict conductor → docs + // merge sequence. Must fail fast. + $api = new FakePullRequestApi(); + $targets = [ + new PullRequestTarget('a/x', 'b1', 'master', RoleLabel::Conductor, 2), + new PullRequestTarget('a/y', 'b2', 'master', RoleLabel::Docs, 1), + ]; + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Conductor before Docs'); + (new ShipReleaseOrchestrator($api, new FakeClock()))->ship($targets); + } + public function testMergeFailureRetractsApprovalStatus(): void { // Verify the success status is followed by a failure status on the