DevOps
Your GitHub template repo is a one-time copy — we found out across 68 sites
Achedon12 Dev.to (EN Zone)
5 views
We run about a hundred repositories. Sixty-eight of them started life the same
way: someone opened a GitHub template repo, clicked Use this template, and
got a working site.
Last week I counted which version of that template each one was actually
running. Twenty-eight distinct versions were live at the same time, from
1.0.0 to 4.35.4. Four sites were still on major version 1.
I want to be precise about the cause, because it is not what it looks like.
Nobody had been lazy, and no process had broken down. The sites had diverged
because there was never a mechanism for them not to.
The copy is one-way, and git will tell you so
A repository created from a GitHub template shares no commit with the
template. Not a distant ancestor — none at all. GitHub squashes the template's
history into a single initial commit in the new repo.
Which means the obvious thing fails:
$ git remote add template https://github.com/acme/service-template.git
$ git fetch template
$ git merge template/main
fatal: refusing to merge unrelated histories
You can force it with --allow-unrelated-histories, and you should not. Git
will then treat every file as a conflicting add, because from its point of view
these are two unrelated projects that happen to have the same filenames.
git rebase has the same requirement, for the same reason. Both operations are
defined in terms of a merge base, and there isn't one.
So the template gains a security fix on day 30, a CI change on day 90, a
dependency bump on day 200 — and none of it reaches any child repo. Someone
copy-pastes it into a handful of repos, or it does not happen. At five repos
that is tedious. At sixty-eight it does not happen.
Cherry-pick does not need an ancestor
There is exactly one git operation that does not care about a common ancestor:
cherry-pick. It does not merge histories, it applies a patch.
git cherry-pick --no-commit <template-sha>
That works perfectly well between two repositories that have never met.
And it is not a workaround — it produces a strictly better result than copying
files. Commit with the original author and date:
git commit \
--author="$(git log -1 --format='%an <%ae>' $SHA)" \
--date="$(git log -1 --format=%aD $SHA)" \
-m "$(git log -1 --format=%B $SHA)"
…and the upstream fix stays findable in the child repo's history:
$ git log --oneline
7c8ddeb Fix: token refresh raced with retry # authored upstream, months ago
67e64eb Bump actions/checkout to v5
641c0bd Initial commit from template
An rsync would have given you one opaque "sync with template" commit, dated
today, authored by whoever ran the script. Six months later, when you are
bisecting, that difference is the whole ballgame.
The hard part is not the cherry-pick
Getting the patch to apply is a weekend. Getting it to apply without
destroying what makes each repo its own is where the real work is, and where
we got it wrong more than once.
One protection rule is not enough
Our first version had a single list of protected paths: on conflict, the local
repo wins. It lost a site's entire branding within a week.
The reason is subtle. A template commit can rewrite your logo cleanly —
no conflict, nothing for anyone to arbitrate, no warning. A conflict-only rule
never fires, and the file is silently replaced.
So there have to be two rules:
Files the repo owns outright — logo, favicon, hostnames, .env,
docker-compose.yml. Restored from the repo after every replayed commit,
conflict or not. These are unmergeable by nature: there is no sensible
three-way merge of a PNG or of a hostname.
Files the repo owns partly — a theme stylesheet that is 90% template and
10% your palette, a translations directory. Here the local version wins only
on conflict. Protecting them absolutely would deny them every upstream fix
forever; not protecting them would erase your part on the first conflicting
commit.
Everything else belongs to the template. And every file the template overwrites
gets printed at the end of the run — more on that below.
Merge commits are not skippable, they are forbidden
Our first run stopped dead on a repo whose template history contained merges.
cherry-pick refuses a merge commit unless you name a mainline with -m, and
if you "unblock" it that way you duplicate content that both parents already
contributed.
The correct answer is --no-merges, always. A merge commit carries no content
of its own; both its parents are already in the range you are replaying.
Half-applied is worse than not applied
If commit 14 of 20 fails, the tempting thing is to stop and report. Do not.
The repo now carries part of the template's changes while its version file — and
every dashboard, script and flag keyed off that version — claims a state it is
not in. Every later run computes its range from that lie and compounds it.
Roll the whole run back to where it started, tag the pre-run state first, and
say which commit stopped you.
The 502 that git reported as a success
The incident that shaped the tool more than any design discussion:
config/nginx/shared.conf contains the PHP-FPM container name, repeated three
times in fastcgi_pass directives. It is not a logo, so it was not on anybody's
protected list. The template's version overwrote it. nginx began looking for a
container that did not exist on that host, and the site returned 502 for
every request — while git log showed a clean, successful, complete update.
Nothing failed. That is exactly what made it bad.
So now: every file the template wins is listed at the end of the run, by name.
It is the only place a graft can lose something, so it is the one thing that
must never be silent.
! The template won 1 conflicted file:
config/nginx/shared.conf
This is the only place a graft loses something. If any of these belong to
this repo rather than the template, add them to `protect` in .regraft.yml
and re-run from 641c0bd.
Knowing where you left off
The last piece: on the next run, which commits are missing?
Storing that in a file means a file to keep in sync, to merge, and to lose. We
put it in the commit itself, as a trailer:
Fix: token refresh raced with retry
Regraft-source: 3d9e77b0a1c4e8f2d5b9a7c6e3f1d8b2a4c9e7f5
The next run reads the most recent one and starts from there. It survives
clones, forks and rebases, because the record lives inside the history it
describes.
Where this ended up
I extracted the tooling into a CLI, because none of the above is specific to our
stack — it is specific to GitHub template repositories, which everyone uses
and nobody can update.
npx regraft init --template https://github.com/acme/service-template.git
npx regraft status # read-only: what am I missing?
npx regraft apply # graft it
It drives the git binary, so the repository can be in any language. There is
also a GitHub Action that opens the pull requests on a schedule, including a
Dependabot-style mode where each template commit becomes its own reviewable PR.
Source: github.com/achedon12/regraft
Docs: achedon12.github.io/regraft
MIT. status touches nothing, so it is safe to point at a repo you care about
just to see the number.
If you maintain repos created from a template, run the count. Twenty-eight
versions was not a number I expected to find, and I am fairly sure we are not
unusual.
Read original: https://dev.to/achedon12/your-github-template-repo-is-a-one-time-copy-we-found-out-across-68-sites-26j3
← Previous
Context Hydration: When Memory Becomes Voice
Next →
Why I am not sad that the machine writes the code
Related
Moving away from Gmail for email storage
DevOps
0
Reddit r/webdev
Track AI Token Spend in Grafana: Claude, Codex, and Ollama
DevOps
2
Dev.to (EN Zone)
EU-Funded CodeSupply Offers Grants for Open Source Software Supply Chain R&D
DevOps
2
DevOps.com
DevOps in Financial Services: Moving Fast Without Losing Control
DevOps
2
DevOps.com
Comments0
No comments yet — be the first