Skip to content

feat: add check-migration-order action, wired into pull-request-kotlin [CPONETOPS-1361] - #343

Merged
mtib merged 2 commits into
mainfrom
feat/CPONETOPS/migration-order-action
Sep 11, 2026
Merged

feat: add check-migration-order action, wired into pull-request-kotlin [CPONETOPS-1361]#343
mtib merged 2 commits into
mainfrom
feat/CPONETOPS/migration-order-action

Conversation

@mtib

@mtib mtib commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Description

Adds a shared check-migration-order composite action and runs it from pull-request-kotlin.yml, so every Kotlin repo on the shared workflow gets the check with no per-repo configuration.

It fails a PR that adds a Flyway migration sorting before one already applied on the base branch, misnames a new migration, or edits/renames/deletes a committed one.

What's in this PR

1. The action.github/actions/check-migration-order/ (action.yml, check-migration-order.sh, README.md).

2. The wiring — a check-migration-order job in pull-request-kotlin.yml:

  check-migration-order:
    name: Check Migration Order
    runs-on: linux-arm64
    timeout-minutes: 5
    steps:
      - name: Checkout
        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
        with:
          # the actual pull request commit, not the merge commit
          ref: ${{ github.event.pull_request.head.sha }}
          fetch-depth: 0
          filter: blob:none
      - name: Check migration order
        uses: monta-app/github-workflows/.github/actions/check-migration-order@main

No with: block — the action discovers its own roots. It's an independent job (no needs: setup) on linux-arm64 so it runs in parallel and finishes in seconds rather than waiting on runner resolution. Callers get it automatically; nothing to add per repo.

Why a composite action rather than a reusable workflow

A reusable workflow's status check is named <caller job name> / <inner job name> — this repo's own pull-request-kotlin.yml proves it: its Setup / Lint / Test with code coverage jobs surface as Pull Request / Setup and so on. Routing service-ocpi through a reusable workflow would therefore rename its required check Check Migration File Naming and force a branch-protection change. A composite action leaves the caller's job name untouched.

Layout follows the existing argocd-wait-sync / pr-title-check convention: action.yml + README.md + a separate .sh invoked via "${{ github.action_path }}/...".

Why the check exists

Flyway runs without outOfOrder, so a version sorting before an already-applied one fails validation and kills the deploy's migration step. With an ArgoCD PreSync hook the sync never completes and the environment silently stays on the old image.

Not hypothetical: service-charges #878 added V2026.09.02.10.00 and merged after #892 added V2026.09.04.10.00. Staging applied #892 first, 09.02 became pending behind it, and four consecutive deploys failed while staging sat on a stale image for a day.

The load-bearing detail is comparing against the base branch tip, not git merge-base. The merge base cannot contain migrations that landed after the branch was cut, so a merge-base comparison — which is what service-ocpi's existing check does — cannot catch that case at all.

Auto-discovery, and the trap it avoids

migration-paths defaults to empty, meaning discover: every directory named migration under src/main/resources holding a .sql somewhere beneath it, unioned from the working tree and the base branch.

The root is the migration directory itself, not every directory that contains a .sql. That distinction is the whole correctness argument. Flyway pools a root's subdirectories (common/, <env>/, stored/procedures/) into one version namespace. service-charges has db/migration/common at 2026.09.09 and db/migration/stored/procedures at 2021.10.27; if those were treated as separate roots, a new stored/procedures migration dated 2022 would pass (2022 > 2021) while Flyway rejects it out-of-order against the pooled 2026 ceiling. A false pass is exactly the bug this check exists to prevent, so discovery stops at the migration directory.

Conversely, genuinely separate databases must not be pooled. In service-ocpp the processor's newest migration is from 2026 and the gateway's from 2022, and I confirmed gateway depends on :common/:gateway-core, not :processor-common — separate classpaths, separate Flyway instances. A single repo-wide root wrongly rejects a valid new gateway migration.

QA

Swept every Kotlin checkout. Discovery finds the right number of independent namespaces in each, all green:

shape repos roots
src/main/resources/db/migration charges, ocpi, charge-points, grid, integrations, support, notifications, kafka-scheduler, internal-ocpi-tooling 1
nested module alerts, template-micronaut (app/…) 1
two modules, two databases ocpp (processor-common + gateway), api-gateways (partner-api + public-api), control (cloud-emulator + ocpp-proxy) 2
MySQL + ClickHouse energy (db/ + clickhouse/), wallet (persistence/db/ + testing/clickhouse/) 2
resources/migration, no db/ data-fusion 1
no migrations vehicle, cpi-api 0 — "nothing to check", exit 0

Failure detection verified by replaying the real service-charges incident, four diagnostics in one run — note the fourth, which is the pooling case above:

::error ...stored/procedures/V2022.01.01.10.00__stored_behind.sql::Migration 2022.01.01.10.00 does not sort after 2026.09.09.17.15 ...
::error ...common/V2026.09.02.10.00__add_cost_center_id_to_charges_meta.sql::Migration 2026.09.02.10.00 does not sort after 2026.09.09.17.15 ...
::error ...common/V2026.10.01__badname.sql::Bad migration name ...
::error ...common/V2026.08.31.09.00__create_charges_verification_table.sql::... editing, renaming or deleting it breaks Flyway checksum validation ...
FAILED: 4 migration problem(s).

That sweep earned its keep — it caught two bugs in the discovery code before this was pushed: the base-branch derivation required a directory between resources/ and migration/ (so it missed data-fusion's resources/migration entirely), and grep exiting 1 on no-match aborted the whole script under set -e + pipefail, turning "no migrations here" into a hard failure. Both fixed.

shellcheck -x -e SC1091 clean, actionlint clean on the modified workflow, both YAML files parse and action.yml validates as a composite action.

Three things the action does that the per-repo copies didn't:

  • Discovers its own roots, so no repo needs wiring — including the ClickHouse and non-db/ layouts a static default missed.
  • Deepens a shallow clone itself. git merge-base on a shallow clone silently returns the wrong answer rather than failing — too sharp an edge to leave to each caller remembering fetch-depth: 0.
  • Unions roots from the base branch, so deleting an entire migration root cannot dodge the check by no longer existing on disk.

Context

CPONETOPS-1361

Follow-ups once this merges — actions here are consumed at @main, so it takes effect for every caller immediately:

  • Repoint the two open drafts at the action before they merge, so the script never lands in three repos: monta-app/service-ocpi#2521 and monta-app/service-ocpp#3105. ocpi's job name stays Check Migration File Naming, so its required check is unaffected, and ocpp no longer needs its hardcoded two-root loop.
  • monta-app/service-charges#896 is already merged with its inline copy; swap it for the action.

Known limitation: pull_request doesn't re-fire when the base branch moves, so two PRs opened in parallel can still both be green. Closing that needs Require branches to be up to date before merging; without it this is a strong nudge, not a guarantee.

Scope: Flyway-style versioned filenames. Version extraction (strip leading B/V, strip __description) is Flyway-specific — Laravel- or Prisma-style migrations would need a second extraction mode.

Runs it from pull-request-kotlin so every Kotlin repo on the shared workflow
gets it; the job self-skips when src/main/resources/db/migration is absent.
Each migration-paths line is an independent Flyway version namespace, so
multi-module repos with separate databases are not pooled.
@mtib
mtib requested a review from a team as a code owner September 11, 2026 11:43
@mtib
mtib requested review from maoanran and removed request for a team September 11, 2026 11:43
Discovers every "migration" directory under src/main/resources holding a .sql,
unioned from the working tree and the base branch, so ClickHouse roots, nested
modules and resources/migration are covered without per-repo config. The root
is the migration directory itself, not each directory containing a .sql --
Flyway pools common/, <env>/ and stored/procedures/ into one version namespace,
so splitting them would let an out-of-order migration pass.
@mtib mtib changed the title feat: add shared check-migration-order action [CPONETOPS-1361] feat: add check-migration-order action, wired into pull-request-kotlin [CPONETOPS-1361] Sep 11, 2026
@mtib mtib self-assigned this Sep 11, 2026

@morten-andersen morten-andersen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great stuff 🤩

@mtib
mtib merged commit c4f0aac into main Sep 11, 2026
1 check passed
@mtib
mtib deleted the feat/CPONETOPS/migration-order-action branch September 11, 2026 12:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants