Architecture · Azure · AzureDevOps · Entra · GitHub · OAuth 2.0 · Observablity

Operating Azure DevOps-to-GitHub EMU Migrations at Enterprise Scale

Moving a repository is easy to describe. Moving thousands of repositories, during controlled windows, into a GitHub Enterprise Managed Users (EMU) organization without losing operational truth is a different problem.

That is the problem this migration control plane solves. It combines an Azure DevOps extension, an ASP.NET Core API, and a durable background engine to plan migration waves and execute them through GitHub Enterprise Importer (GEI). The product does not replace GEI. It adds the scheduling, safety, auditability, credential management, and verification needed to operate GEI as an enterprise program.

The platform gap

Azure DevOps now has Enterprise Live Migrations (ELM). ELM is a compelling first-party experience: it continuously synchronizes repositories, supports a customer-scheduled cutover, tracks migration lifecycle, and reduces post-migration work for Azure Boards and Azure Pipelines.

ELM is currently a limited public preview with a specific target scope. Its documentation says that the target must be GitHub Enterprise Cloud with data residency, hosted at <enterprise>.ghe.com.

Microsoft states the boundary again in the overview: ELM supports Azure DevOps Services to GitHub Enterprise Cloud with data residency.

Source: ELM overview, captured 31 August 2026.

That matters for an enterprise whose destination is an existing, standard github.com EMU organization. That destination does not satisfy ELM’s currently documented <enterprise>.ghe.com prerequisite. This is not a claim that ELM has no relationship with managed identities: GitHub documents EMU on both GitHub.com and GHE.com. It is a narrower deployment fact: the standard GitHub.com EMU destination in this program is outside ELM’s present target scope.

GEI provides the lower-level path that remains available. GitHub documents Azure DevOps Cloud migrations to GitHub Enterprise Cloud on either GitHub.com or GHE.com, and its migration guidance explicitly asks enterprises to decide whether they will use Enterprise Managed Users.

GEI is the migration mechanism. The control plane in this repository turns that mechanism into an operable service for a large enterprise.

The value it adds

1. It turns repositories into managed waves

Operators do not submit isolated migration commands. They assemble repositories into a wave, assign delegates, seal the plan, bind it to an approved migration window, and schedule it. The daemon, rather than a human button, decides when a scheduled wave is genuinely executing.

This separation of intent from truth is important. Scheduled means the operator has handed the wave to the system. Executing means the engine is actually dispatching work. Pausing means no new work is starting while existing imports drain. Paused means the wave is fully quiescent.

2. It makes off-hours execution enforceable

Reusable migration windows are evaluated in an IANA timezone, such as Europe/Amsterdam, so an 18:00 start remains 18:00 across daylight-saving changes.

The dispatcher rechecks the window immediately before every submission. A window that closes during a dispatch tick cannot leak another migration into business hours.

3. It protects downstream services with live concurrency control

The operator controls a global concurrency limit and a dispatch kill switch. The daemon rereads both on every 15-second dispatch tick.

Raising the limit fills new capacity on the next tick. Lowering it never cancels work already running; the engine simply stops submitting until the in-flight count falls below the new limit. A circuit breaker also halts new submissions when GEI is unhealthy.

4. It handles ambiguous outcomes safely

The dangerous failure is not a clear rejection. It is a timeout or generic GraphQL error after GitHub may already have accepted the migration.

The engine persists Submitting before calling GEI. If the outcome is unclear, the repository enters SubmissionUncertain. A reconciler then looks for the migration instead of blindly submitting it again. This avoids duplicate imports and the misleading secondary errors they create.

5. It makes credentials an operational resource

Azure DevOps and GitHub PATs are selected from health-tracked credential pools at dispatch time. A repository attempt stays bound to the identity that created it because GEI migration visibility can be identity-scoped. Rate limiting cools a credential down without breaking affinity; permanent authentication failure allows a controlled reassignment with an audit event.

Secrets stay in Azure Key Vault. Cosmos DB stores references, health, binding history, and the secret version used by an attempt, not the token itself.

6. It verifies what arrived, not only what GEI reported

Succeeded from the importer is not treated as proof that source and target are identical. The engine records a source snapshot before submission, then reads the source and target again after migration.

That produces useful, classified outcomes:

  • Clean: the expected refs arrived.
  • SourceAdvanced: developers changed the source after the baseline.
  • TargetIncomplete: expected refs are missing from GitHub.
  • TargetUnexpectedRefs: the destination contains unplanned refs.
  • PullRequestActivity: pull-request counts changed during the migration interval.
  • VerificationFailed: the verifier itself could not reach a trustworthy conclusion.

The distinction changes the conversation from “the job is green” to “the repository that landed is demonstrably complete, or we know exactly why confidence is lower.”

7. It makes silence explainable

An always-on daemon can be healthy while doing no work. It can also be alive while one of its loops is stuck. A single liveness endpoint cannot tell those cases apart.

The control plane records loop health and computes an engine verdict such as working, legitimately idle, waiting for a window, deliberately paused, or stalled. Operators can diagnose the system from state rather than infer health from the absence of log errors.

The API authenticates extension calls by presenting the user’s Azure DevOps token to that organization’s _apis/ConnectionData endpoint. Azure DevOps remains the authority for identity and permissions. Administrative actions are enforced server-side, even when the client also hides unavailable controls.

The daemon runs six independent, leader-controlled loops:

LoopResponsibility
Wave activatorOpens and drains waves as migration windows open and close.
DispatcherFills available concurrency with ready repositories.
Status pollerBatches GEI status queries and persists liveness separately from status changes.
ReconcilerResolves uncertain submissions and repairs stuck transient states.
VerificationCompares source-at-submit, source-at-verify, and target-at-verify snapshots.
HeartbeatPublishes an operator-readable health verdict.

Every loop derives work from durable Cosmos DB state. A Ready repository is work waiting to be submitted. An InProgress repository whose nextPollUtc has passed is work waiting to be polled. No correctness-critical instruction exists only in process memory, so a container restart costs at most one tick.

Leader election prevents two daemon instances from dispatching the same work during a rollout. Timeline events are partitioned by wave, matching the operator’s drill-down path and avoiding a single high-volume organization partition for repository history.

The repository state model

Each repository attempt moves through an explicit lifecycle:

Queued -> PreflightValidating -> Ready -> Submitting -> InProgress -> Succeeded
| |
| +-> Verification
+-> SubmissionUncertain Pending -> Running -> Complete
+-> Failed

The wave has its own lifecycle:

Draft <-> Sealed <-> Scheduled -> Executing -> Completed
|
+-> Pausing -> Paused

These two state machines let the UI report both program-level progress and repository-level truth. A wave may complete with individual failures; those failures remain visible as a rollup rather than being hidden behind a vague wave-level status.

Where this complements ELM

ELM and this control plane solve overlapping but different layers of the problem.

ConcernELMThis control plane
Current documented targetGHE.com data-residency enterpriseStandard GitHub.com EMU through GEI
Transfer modelContinuous synchronization and scheduled cutoverGEI repository import attempts
Operator scaleFirst-party portal and CLI, up to 20 concurrent jobs documentedWave planning and a configurable global concurrency ceiling
SchedulingCustomer-scheduled cutoverRecurring timezone-aware execution windows
Ambiguous submissionsManaged within the serviceExplicit SubmissionUncertain reconciliation
CredentialsELM service connection and migration PATHealth-tracked pools with sticky per-attempt affinity
Post-import confidenceValidation workflowRef snapshots, drift classification, and detailed timelines
Runtime observabilityPortal, CLI, and Azure DevOps audit logPer-loop health, heartbeat verdicts, and repository event history

If ELM expands to support the destination and operating model required by this program, some transfer responsibilities may eventually move to the managed service. The control-plane capabilities still retain value: portfolio planning, wave governance, maintenance windows, credential capacity, enterprise-specific verification, and a single operational view across thousands of repositories.

What exists today

The repository contains the control-plane implementation, not only a proposal:

  • The dispatcher enforces leadership, windows, the kill switch, circuit breaking, and dynamic concurrency.
  • The status poller batches status checks by bound credential and persists polling liveness.
  • The GEI client targets https://api.github.com/graphql, submits AZURE_DEVOPS repository migrations, and classifies uncertain mutation outcomes conservatively.
  • The daemon composition registers activation, dispatch, polling, reconciliation, verification, heartbeat, leader election, /healthz, and /status.
  • The Azure DevOps extension includes wave, repository, migration-window, credential-pool, configuration, progress, and migration-detail surfaces.
  • Bicep provisions Azure Container Apps, Cosmos DB, Key Vault, private networking, monitoring, and managed identities.

The documentation has evolved at different speeds: some READMEs still describe earlier scaffold stages, while the source contains the newer execution engine. Production readiness should therefore be judged through the build, tests, deployed configuration, and an end-to-end rehearsal, not from any single status paragraph.

The larger lesson

At enterprise scale, migration success is not merely “did an importer return success?” It is the ability to answer, at any moment:

  • What is allowed to run now?
  • What is actually running?
  • Why is nothing running?
  • Which identity submitted each repository?
  • Did GitHub accept an uncertain request?
  • Did the expected refs arrive?
  • What changed while the migration was in flight?
  • Can the process restart without losing its place?

This control plane makes those answers durable and visible. That is its real value: it turns a collection of repository imports into a migration operation that an enterprise can schedule, govern, inspect, and trust.

References

  1. Microsoft Learn, Introduction to Enterprise Live Migrations.
  2. Microsoft Learn, Prerequisites for Enterprise Live Migrations.
  3. GitHub Docs, Understand migrations from Azure DevOps to GitHub.
  4. GitHub Docs, About Enterprise Managed Users.

Platform capabilities and preview restrictions change. The ELM scope in this article reflects the official documentation accessed on 31 August 2026.

Leave a comment