<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pulkit Aggarwal</title>
    <description>The latest articles on DEV Community by Pulkit Aggarwal (@pulkit0_7).</description>
    <link>https://dev.to/pulkit0_7</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4190328%2F51cbc4ad-a507-40c2-afad-1aa3308c5913.png</url>
      <title>DEV Community: Pulkit Aggarwal</title>
      <link>https://dev.to/pulkit0_7</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pulkit0_7"/>
    <language>en</language>
    <item>
      <title>Migration to GitHub EMU: Lessons Learned</title>
      <dc:creator>Pulkit Aggarwal</dc:creator>
      <pubDate>Sun, 18 Oct 2026 03:58:46 +0000</pubDate>
      <link>https://dev.to/pulkit0_7/migration-to-github-emu-lessons-learned-ihk</link>
      <guid>https://dev.to/pulkit0_7/migration-to-github-emu-lessons-learned-ihk</guid>
      <description>&lt;h1&gt;
  
  
  Migrating 1,400+ repos to GitHub EMU: lessons learned
&lt;/h1&gt;

&lt;p&gt;A couple of months back, I led the migration of multiple GitHub organisations with about 1,400+ repositories from github.com to GitHub Enterprise Managed Users (EMU). This post collects what the official docs and GitHub's own migration tooling leave out.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Decide your &lt;strong&gt;org name strategy&lt;/strong&gt; on day one. If your org name is referenced throughout your code, the two-rename swap keeps every &lt;code&gt;github.com/your-org/...&lt;/code&gt; URL and Go import path intact (explained later in the post). This whole guide is written considering one is following the rename to the same org name approach but there are parts of this which is useful to everyone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't depend on &lt;code&gt;gh-gei&lt;/code&gt; at scale.&lt;/strong&gt; Custom mirror-and-push tooling, validated with a separate framework, replaced it. This tooling is not great, bad logs and no understanding of why a repository fails migration. Large repositories just cannot depend on this. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets, Apps, webhooks, variables, runners and branch protections don't come with you.&lt;/strong&gt; Inventory them before the cutover, not during it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The org rename and the IdP update must happen together.&lt;/strong&gt; Any gap, and every user loses SSO access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollback is clean only until you rename the source org.&lt;/strong&gt; After that, it's forward-fix.&lt;/li&gt;
&lt;li&gt;This migration is going to be really hard as there's a lot of moving parts.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why EMU
&lt;/h2&gt;

&lt;p&gt;With a regular GitHub organisation, people sign in with personal accounts that they own and carry between employers, projects and their own side work. SSO can sit in front of the org, but the identity itself isn't yours. EMU flips that. Every account is created and managed through your identity provider, and it only exists inside your enterprise. When someone is de-provisioned in the IdP, their GitHub access goes with it.&lt;/p&gt;

&lt;p&gt;Policy is set once at the enterprise level rather than org by org. And because managed users can't contribute outside the enterprise, there is a clear boundary around where your code lives and who can touch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two decisions that shape everything in this migration
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Keep your org name: the two-rename swap
&lt;/h3&gt;

&lt;p&gt;Go modules embed &lt;code&gt;github.com/{org}/{repo}&lt;/code&gt; in &lt;code&gt;go.mod&lt;/code&gt; and in every import of an internal package. If the org name changes, every internal import path in every repo breaks at once, and fixing hundreds of repos in a coordinated way is effectively impossible.&lt;/p&gt;

&lt;p&gt;The fix is to migrate into a temporary EMU org, then swap names:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Migrate everything to &lt;code&gt;your-org-emu&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Rename the source org: &lt;code&gt;your-org&lt;/code&gt; to &lt;code&gt;your-org-legacy&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Rename the destination: &lt;code&gt;your-org-emu&lt;/code&gt; to &lt;code&gt;your-org&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The destination ends up with the original name, so nothing downstream changes. Even if you aren't a Go shop, the swap protects every hardcoded org reference in CI config, scripts, docs and service configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Note
&lt;/h3&gt;

&lt;p&gt;This isn't the right call for everyone. It depends on how deeply your org name is baked into your code, how much data you have, and how much risk you're willing to take. If references are few, renaming and fixing them gradually may be cheaper than a coordinated cutover. In this case the references were everywhere, so the risk of a single cutover event was accepted.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Build your own migration tooling
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;gh-gei&lt;/code&gt; fails non-deterministically at scale. At 1,000+ repos, some percentage of runs fail for reasons unrelated to the repo, and you won't have time to wait for support. The core of a git migration is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Clone the source as a bare mirror&lt;/span&gt;
git clone &lt;span class="nt"&gt;--mirror&lt;/span&gt; https://x-access-token:&lt;span class="o"&gt;{&lt;/span&gt;token&lt;span class="o"&gt;}&lt;/span&gt;@github.com/&lt;span class="o"&gt;{&lt;/span&gt;source_org&lt;span class="o"&gt;}&lt;/span&gt;/&lt;span class="o"&gt;{&lt;/span&gt;repo&lt;span class="o"&gt;}&lt;/span&gt;.git

&lt;span class="c"&gt;# GitHub blocks pushing refs/pull/*, so fetch them under a different name&lt;/span&gt;
git config &lt;span class="nt"&gt;--add&lt;/span&gt; remote.origin.fetch &lt;span class="s1"&gt;'+refs/pull/*:refs/migrated-pull-request/*'&lt;/span&gt;
git fetch origin

&lt;span class="c"&gt;# Push everything to the destination&lt;/span&gt;
git push &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--no-thin&lt;/span&gt; https://x-access-token:&lt;span class="o"&gt;{&lt;/span&gt;token&lt;span class="o"&gt;}&lt;/span&gt;@github.com/&lt;span class="o"&gt;{&lt;/span&gt;dest_org&lt;span class="o"&gt;}&lt;/span&gt;/&lt;span class="o"&gt;{&lt;/span&gt;repo&lt;span class="o"&gt;}&lt;/span&gt;.git &lt;span class="nt"&gt;--all&lt;/span&gt;
git push &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--no-thin&lt;/span&gt; https://x-access-token:&lt;span class="o"&gt;{&lt;/span&gt;token&lt;span class="o"&gt;}&lt;/span&gt;@github.com/&lt;span class="o"&gt;{&lt;/span&gt;dest_org&lt;span class="o"&gt;}&lt;/span&gt;/&lt;span class="o"&gt;{&lt;/span&gt;repo&lt;span class="o"&gt;}&lt;/span&gt;.git &lt;span class="nt"&gt;--tags&lt;/span&gt;

&lt;span class="c"&gt;# LFS, if the repo has it&lt;/span&gt;
git lfs fetch &lt;span class="nt"&gt;--all&lt;/span&gt;
git lfs push &lt;span class="nt"&gt;--all&lt;/span&gt; https://x-access-token:&lt;span class="o"&gt;{&lt;/span&gt;token&lt;span class="o"&gt;}&lt;/span&gt;@github.com/&lt;span class="o"&gt;{&lt;/span&gt;dest_org&lt;span class="o"&gt;}&lt;/span&gt;/&lt;span class="o"&gt;{&lt;/span&gt;repo&lt;span class="o"&gt;}&lt;/span&gt;.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wrapper around it is where the reliability comes from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Per-repo status tracking: succeeded, failed, skipped&lt;/li&gt;
&lt;li&gt;Automatic retry with backoff&lt;/li&gt;
&lt;li&gt;Commit-drift detection before any re-migration&lt;/li&gt;
&lt;li&gt;A pool of GitHub Apps, one per concurrent job, so rate limits don't contend across repos&lt;/li&gt;
&lt;li&gt;Ref batching (500 at a time, falling back to one-by-one for problem repos)&lt;/li&gt;
&lt;li&gt;Incremental re-runs that skip refs already on the destination with matching SHAs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And validate with a separate framework. Don't trust the migration tool's own exit codes as github api can be very flaky.&lt;/p&gt;

&lt;h2&gt;
  
  
  What migrates and what doesn't
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Commits, branches, tags, LFS objects, labels, releases&lt;/td&gt;
&lt;td&gt;Full fidelity (draft releases excluded)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pull requests and issues&lt;/td&gt;
&lt;td&gt;Partial: authorship, reactions and assignees are lost, drafts become open PRs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Projects&lt;/td&gt;
&lt;td&gt;Custom tooling; sort/group settings and automation workflows are lost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discussions&lt;/td&gt;
&lt;td&gt;Custom tooling; custom categories need browser automation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actions secrets&lt;/td&gt;
&lt;td&gt;Write-only API, needs its own strategy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actions variables&lt;/td&gt;
&lt;td&gt;Not migrated, but readable via API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Apps&lt;/td&gt;
&lt;td&gt;Not transferable, must be reinstalled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Branch protections, rulesets, environments, teams&lt;/td&gt;
&lt;td&gt;Not migrated, rebuilt after&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webhooks, deploy keys, runners, fork relationships&lt;/td&gt;
&lt;td&gt;Not migrated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSH keys&lt;/td&gt;
&lt;td&gt;Not migrated, each engineer regenerates&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Because surprises during the cutover (during the rename) are expensive, an audit of the source org came first, covering repos by visibility, LFS, wikis, Discussions, Projects, Apps and installation IDs, secrets (names only), webhooks, variables, runners and CODEOWNERS files. That inventory sized the tooling, flagged per-repo exceptions, and became the validation baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lessons that hurt
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Secrets are three separate problems
&lt;/h3&gt;

&lt;p&gt;GitHub's Secrets API is write-only. Nobody, including org admins, can read a value back. That applies to org secrets, repo secrets and Dependabot secrets, and each needs a different approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Org secrets:&lt;/strong&gt; pre-populate each as a repo secret on a migration repo; a workflow reads them via &lt;code&gt;env:&lt;/code&gt; and writes them to the destination org with &lt;code&gt;gh secret set&lt;/code&gt;, preserving visibility.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repo secrets:&lt;/strong&gt; generate a per-repo workflow, push it to a branch, and trigger them all centrally with staggered delays. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependabot secrets:&lt;/strong&gt; a separate API again. Source the values from the equivalent Actions org secrets or your secrets manager.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  SSO and the rename must be simultaneous
&lt;/h3&gt;

&lt;p&gt;Update the IdP configuration at the same moment as the rename. Any gap, and every user loses SSO access. This 15 to 30 minute window was the highest-risk part of the migration. This can be restored by Github Support if things go wrong still.&lt;/p&gt;

&lt;h3&gt;
  
  
  CODEOWNERS fail silently
&lt;/h3&gt;

&lt;p&gt;EMU usernames follow a new format, and GitHub gives no error or warning when CODEOWNERS references unknown users. You just lose enforcement. Generate a username mapping CSV from your directory (LDAP/AD) early, since many downstream steps depend on it, then script a rewrite that opens and auto-merges a PR in every repo. Drop usernames with no EMU equivalent. Also note that Apps can't be code owners in EMU, so automation needs an EMU-managed service account.&lt;/p&gt;

&lt;h3&gt;
  
  
  Team Sync doesn't work with EMU
&lt;/h3&gt;

&lt;p&gt;The Team Sync API returns &lt;code&gt;403 - This team is not externally managed&lt;/code&gt;. Use the External Groups API instead. If you use Terraform, swap &lt;code&gt;github_team_sync_group_mapping&lt;/code&gt; for &lt;code&gt;github_emu_group_mapping&lt;/code&gt;. And remember that your IdP groups must be explicitly assigned to the EMU Enterprise Application, or they simply won't appear and your mappings will silently come back empty.&lt;/p&gt;

&lt;h3&gt;
  
  
  Issue and PR numbers must not shift
&lt;/h3&gt;

&lt;p&gt;Teams reference issue and PR numbers everywhere: Jira tickets, Confluence pages, commit messages, Slack threads. If the numbers shift during migration, every one of those references quietly points at the wrong thing, and nothing tells you.&lt;/p&gt;

&lt;p&gt;They shift more easily than you'd expect. If an issue was deleted in the source, a naive migration just skips it, and every later number moves down by one. The fix is to migrate PRs and issues in ascending order and track the number you expect next. When there's a gap, create a placeholder issue to hold the slot, label it &lt;code&gt;emu-gap-#number-of-issue&lt;/code&gt; and close it straight away. That way #42 in the source lands as #42 in the destination. Failures get the same treatment. If an item can't be migrated, an error placeholder holds its number and records the failure, instead of letting everything after it shift.&lt;/p&gt;

&lt;p&gt;A few things to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Placeholders are inert. Exclude them from comment migration and relationship syncing.&lt;/li&gt;
&lt;li&gt;They inflate your issue and PR counts, so validation should treat a count difference as a warning, not a failure.&lt;/li&gt;
&lt;li&gt;For repos where numbers don't matter, such as high-volume repos full of machine-generated PRs, turn placeholders off. Thousands of them add nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The small ones that bite
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wikis:&lt;/strong&gt; the wiki repo can't be created via API, so content silently skips unless someone creates the first page in the destination by hand. Do it before the run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discussion categories:&lt;/strong&gt; no API exists, so Playwright handled it, and the Discussions migration was gated on a script that compares source and destination categories.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Projects:&lt;/strong&gt; &lt;code&gt;gh migrate-project&lt;/code&gt; can't rotate tokens, and App tokens die after an hour, so large projects need to run sequentially with a classic PAT. Archive stale projects beforehand, screenshot view settings, and document automation workflows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actions variables:&lt;/strong&gt; values are readable, so export and recreate via API.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Order of operations
&lt;/h2&gt;

&lt;p&gt;The sequence mattered, and deviating from it created race conditions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Lock the source org with rulesets so nothing can push.&lt;/li&gt;
&lt;li&gt;Block IaC and operators that manage teams or repos.&lt;/li&gt;
&lt;li&gt;Pause GitOps controllers.&lt;/li&gt;
&lt;li&gt;Pre-create wiki repos in the destination.&lt;/li&gt;
&lt;li&gt;Migrate repos in batches, validating each.&lt;/li&gt;
&lt;li&gt;Migrate Projects and Discussions in parallel.&lt;/li&gt;
&lt;li&gt;Run full validation and fix failures before the rename.&lt;/li&gt;
&lt;li&gt;Rename source to &lt;code&gt;-legacy&lt;/code&gt;, then destination to the original name.&lt;/li&gt;
&lt;li&gt;Update the IdP immediately.&lt;/li&gt;
&lt;li&gt;Repair: branch protections, CODEOWNERS PRs, App access.&lt;/li&gt;
&lt;li&gt;Reconcile GitOps CRs.&lt;/li&gt;
&lt;li&gt;Reinstall runners and reconnect integrations.&lt;/li&gt;
&lt;li&gt;Validate services end to end.&lt;/li&gt;
&lt;li&gt;Enable Actions and remove the blocking rulesets.&lt;/li&gt;
&lt;li&gt;Tell engineers to regenerate SSH keys.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Validation
&lt;/h2&gt;

&lt;p&gt;Parallelise from the start. With 40 workers, validating 1,300+ repos took about an hour, versus 2 to 4 hours sequentially. Checks covered commit counts, branch and tag SHAs, default branch, metadata, issue/PR/release counts, milestones, wiki presence, CODEOWNERS presence and discussion counts.&lt;/p&gt;

&lt;p&gt;Then validate the services, not just the repos: submit test jobs on every runner type, resolve a module through the proxy, mint a token, confirm OIDC, and open a test PR to check code owner enforcement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rollback
&lt;/h2&gt;

&lt;p&gt;Define abort criteria and decision authority before the cutover. Before the rename, rollback is clean: the source is read-only but intact, so you lift the rulesets, resume automation and reschedule. After the rename it's forward-fix only. The legacy org remains as a safety net (keep it for at least 3 months), and individual repos can be re-pushed from it. GitHub also imposes a waiting period between renames, so don't assume you can reverse one quickly. You need good coordination with GitHub Support for this rename process and I'd recommend getting them involved early.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running an EMU migration?
&lt;/h2&gt;

&lt;p&gt;There's a thousand more things you need to think about&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;terraform state files&lt;/li&gt;
&lt;li&gt;flux cd/ argo cd reconfiguration&lt;/li&gt;
&lt;li&gt;github apps&lt;/li&gt;
&lt;li&gt;github runners&lt;/li&gt;
&lt;li&gt;ip allowlists&lt;/li&gt;
&lt;li&gt;github settings&lt;/li&gt;
&lt;li&gt;github team memberships and roles
... list goes on and on&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's a lot more I could add to this post, but if you're running an EMU migration and want help working through the decisions, I'm always up for a coffee or a call. I'm based in Melbourne, and you can DM me on LinkedIn.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.linkedin.com/in/pulkit07/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/pulkit07/&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CRs:&lt;/strong&gt; custom resources (the Kubernetes objects that GitOps operators manage)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EMU:&lt;/strong&gt; Enterprise Managed Users&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;gh-gei:&lt;/strong&gt; the GitHub CLI extension for GitHub Enterprise Importer (GEI), GitHub's own migration tool&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IaC:&lt;/strong&gt; infrastructure as code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IdP:&lt;/strong&gt; identity provider&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LDAP/AD:&lt;/strong&gt; Lightweight Directory Access Protocol / Active Directory&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LFS:&lt;/strong&gt; Large File Storage (Git LFS)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OIDC:&lt;/strong&gt; OpenID Connect&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PAT:&lt;/strong&gt; personal access token&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Other Great Source to Read: &lt;a href="https://github.com/orgs/community/discussions/189382" rel="noopener noreferrer"&gt;https://github.com/orgs/community/discussions/189382&lt;/a&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>devops</category>
      <category>github</category>
      <category>infrastructure</category>
    </item>
  </channel>
</rss>
