Blog
Sep 16, 2026-9 MIN READ
Open-Sourcing One Package from a Private Monorepo

Open-Sourcing One Package from a Private Monorepo

You want one package public and the rest of the monorepo private, without copy-paste drift and without giving up the monorepo. git subtree split does it, the monorepo stays the source of truth, and the whole thing fits in a script with four guards.

By Baljeet Singh

I've a private monorepo with a package in it that other people would find useful. The rest of the repo — apps, migrations, business logic — isn't going public, and I didn't want to break the package out just to share it.

The thing I wanted to avoid was the copy-paste mirror. Publish a snapshot, then next release remember to copy the changes across, and three months later the public one is a version behind with a bug you already fixed.

git subtree split solves this properly, and the whole mechanism is about forty lines of bash.

What Subtree Split Actually Does

It rewrites your history down to one directory. Same commit messages, same authors, same dates, but each diff restricted to the package:

split="$(git subtree split --prefix=packages/mypackage)"

You get back a commit SHA. That commit's tree is the package directory, and its history is your monorepo's history with everything else filtered out. Nothing in your repo changes — it is a read operation that produces a new object.

Push that SHA to a public repo's main and you have a mirror.

git push git@github.com:you/mypackage.git "${split}:refs/heads/main"

Capture that SHA into a variable and quote it every time. An unset $split expands to nothing, and git push <remote> ":refs/heads/main" is git's syntax for deleting main on the other end. The same slip on the tag push deletes the tag. Nothing warns you; the push succeeds and reports a deletion.

The property that makes this work in practice: the split is deterministic. Run it again tomorrow after three more commits and you get a history that extends the one you pushed. So every push is a fast-forward, and you never need --force.

That holds as long as nobody commits directly on the mirror, and as long as main's own history is never rewritten. A rebase or a squash-merge on main breaks every later fast-forward. Those are the two rules the whole model depends on.

One thing to be clear about before you point this at a private repo: the split carries the package's entire history, not its current contents. Every commit that ever touched the prefix goes public, including files you deleted. If a key, a fixture or a customer name ever sat under that directory, it is in the mirror, and none of the guards below will catch it — they all compare the tip. Check with git log --diff-filter=D -- packages/mypackage before the first push, and treat that first push as irreversible.

The Script, and Its Four Guards

A one-liner would work and would eventually hurt you. It opens with two lines that make everything after them mean anything:

set -euo pipefail
cd "$(git rev-parse --show-toplevel)"

Without set -u an unset $split fails silently in the way described above. Without the cd, the second guard checks the wrong directory: git status --porcelain -- "$PREFIX" resolves its pathspec relative to where you are, so run it from a sibling directory and it prints nothing and waves a dirty tree straight through. node -p "require('./$PREFIX/package.json')" breaks the same way.

Then four checks, each for a failure I could see coming:

Refuse to run off main.

branch="$(git rev-parse --abbrev-ref HEAD)"
if [ "$branch" != "main" ]; then
  echo "on '$branch'; the mirror tracks main's history only." >&2
  exit 1
fi

Splitting a feature branch pushes a history the mirror has never seen and probably never should. That's the one case that would need a force-push to undo.

Refuse if the package has uncommitted changes.

if [ -n "$(git status --porcelain -- "$PREFIX")" ]; then
  echo "uncommitted changes under $PREFIX would not be mirrored." >&2
  exit 1
fi

The split reads committed history. Local edits are silently excluded, so you would push, see success, and wonder why your fix isn't there.

Verify the split tree is actually the package.

if [ "$(git rev-parse "$split^{tree}")" != "$(git rev-parse "HEAD:$PREFIX")" ]; then
  echo "split tree does not match $PREFIX; refusing to push." >&2
  exit 1
fi

This is the good one. The split's tip tree must be byte-identical to the package directory at HEAD. If a rename, a submodule or a wrong prefix produced something else, nothing goes out. It costs two rev-parse calls and it is the difference between a mirror you trust and one you check by hand.

Refuse to move a tag that already exists. Tagging a release is the same push to a different ref:

version="$(node -p "require('./$PREFIX/package.json').version")"
tag="v$version"
existing="$(git ls-remote --tags "$REMOTE" "refs/tags/$tag" | cut -f1)"
if [ -z "$existing" ]; then
  git push "$REMOTE" "${split}:refs/tags/${tag}"
elif [ "$existing" != "$split" ]; then
  echo "$tag is already on the mirror at ${existing:0:8}, not at this split." >&2
  exit 1
fi

Re-running on the same commit is a no-op. Re-running after the version moved is an error you have to resolve by hand, because a published tag pointing somewhere new is the one thing consumers cannot recover from.

Not a check, but the property the other four protect: there's no --force anywhere in the script. If a push is rejected, an assumption is wrong, and the right response is to look rather than to overwrite.

Taking Contributions Without Merging the Mirror

This is the part I had not thought through, and it is where the model could fall apart.

Someone opens a pull request on the mirror. If you merge it there, the mirror now has a commit your monorepo has never seen, the next split no longer fast-forwards, and you are force-pushing over a contributor's work.

So you never merge on the mirror. You apply the patch in the monorepo:

git am --directory=packages/mypackage < contribution.patch

--directory re-roots every path in the patch into the package, so a patch written against the mirror's layout applies cleanly inside the monorepo. Authorship is preserved, the commit lands on main, and the next mirror push carries it out with everyone else's.

The contributor's commit still appears on the mirror with their name on it. It just arrives from the other direction.

The social half matters as much as the mechanical one. Somebody who opens a PR and watches it sit there un-merged, then get closed, will reasonably assume they were ignored. So tell them up front, in a pull request template that lives inside the package:

packages/mypackage/.github/PULL_REQUEST_TEMPLATE.md

That path is the trick. GitHub only reads .github/ at a repository root, so the monorepo ignores the file completely — and after the split it is at the root, so the mirror picks it up. One file, invisible upstream, visible exactly where it is needed.

Mine says what actually happens:

This repository mirrors the package from a private monorepo, so it is not merged here: once reviewed, it is applied upstream with your authorship kept, lands with the next mirror push, and is then closed with a pointer to that commit.

Then close the PR with a link to the commit that carried it. The contributor can see their name on it.

Making the Package Stand Alone

A package inside a monorepo quietly leans on the root for things it won't have once extracted.

Config it was inheriting. The root .gitignore, the root formatter config, the root TypeScript setup. Each of those needs a copy inside the package. I moved the package's block out of the root ignore file entirely, so the package file is the only one that governs it.

Dependencies the root was providing. typescript and @types/node were hoisted from the workspace root. Nothing in the package's own manifest declared them, so a clean npm install of the extracted package could not build it.

Links that point at nothing. repository and bugs in package.json should point at the public mirror, not the private monorepo.

Images in the README. Use a relative path, not an absolute URL into the monorepo — docs/public/og-image.png rather than a link to a host. A relative path renders on both repositories, because after the split the file sits at the same place relative to the README. One caveat I have not verified end to end: npm rewrites relative image paths through the repository field when it renders a README, so check the package page after your next publish rather than assuming.

References to private code. This is the one to search for carefully. A README that names internal modules, or a source comment pointing at something only the monorepo has, is not a secret exactly — but it is confusing to a reader at best and a sketch of your internal structure at worst. Grep the package for the names of your other packages before the first push, and remember that the history goes too, so fixing it at the tip is not fixing it.

Verify It Somewhere Else

The check that matters isn't that it builds in the monorepo. It is that it builds without one.

Copy the package directory somewhere outside the repo, run a clean install, then run the full set — typecheck, tests, the dist build, the docs build. Everything that passes in the monorepo has to pass in a bare directory with no workspace around it.

That is what catches the hoisted dependency and the config file you forgot. It found both for me.

What Stays Behind

The docs site still builds from the monorepo. There was no reason to move it, the mirror does not need a deploy pipeline, and the public repo stays what it should be — source, README, licence, changelog.

And one line in the repo's CLAUDE.md saying everything under that directory is public, because the thing most likely to go wrong in six months is somebody — me, a colleague, an agent — writing an internal note in a file that gets mirrored to the world.

The Shape of It

The monorepo is the source of truth. The mirror is derived, always, by a deterministic function. Contributions flow inward as patches and outward as splits. No copy-paste, no drift, nothing to remember at release time beyond one extra command.

The release sequence, in order, because getting it wrong means a tag pointing at the wrong tree:

  1. Bump the version and land it on main, then push, so HEAD and origin/main agree — otherwise you tag the mirror with a commit that exists only on your laptop
  2. pnpm mirror --tag — split, push, tag the tip with v<version>
  3. pnpm publish --access public

Mirror before publish. The tag has to describe a commit that is already public, or the first thing someone does after installing your package is click through to a repository that does not have the code they are running.

pnpm, not npm, and it is worth a prepublishOnly hook that enforces it: npm ignores publishConfig field overrides, so publishing with it ships a manifest whose main, types and exports still point at src/*.ts. The package installs and then fails to resolve. Mine refuses to publish under npm at all rather than leave that to memory.

I have never needed a force-push.

© 2019-2026 Baljeet Singh. All rights reserved.