The most dangerous moment in a sync's life is the first time you run it.
I write my posts in a git repository and push them to a publishing platform through its API. The sync is one-way and it sends whole articles: fetch everything that's live, compare against what's in the repo, and PUT the ones that differ.
Think about what that does on run number one, when the repo has never been reconciled against live. Every article where my local copy is stale gets overwritten with the stale copy. All at once. On published posts that people have already read.
The usual mitigations are eyeballing a diff, or enabling it for one article first. Neither of them proves anything about the other articles. I wanted a first run whose correctness was structural rather than checked by hand.
Seed from live, not from your repo
The trick is almost too simple to write down: build the initial state of the mirror by downloading what's already published, not by using what you have locally.
If the mirror starts as an exact copy of live, then logically the first sync must find zero differences and change nothing. The first run becomes a no-op by construction.
And that turns the first run into an experiment. If it really does nothing, then my fetching, my normalisation, and my comparison all agree with the platform's own idea of what those articles contain. If it tries to write something, my comparison is wrong — and I've learned that before the write happens, on a run I already expected to be silent.
I called it canary zero: the canary that proves the machinery works by not moving.
Here's the real log from that first run:
⏭ 03-dev-building-a-typed-cms-for-business-data (id=4052442) 差分なし → no-op
⏭ 04-dev-building-self-hosted-business-tools-for-japan (id=4024309) 差分なし → no-op
⏭ 09-dev-disposable-tenant-demo (id=4099005) 差分なし → no-op
⏭ 11-dev-introducing-nene2-ai-readable-business-apis (id=3957239) 差分なし → no-op
⏭ 12-dev-mcp-should-not-touch-your-database (id=3989175) 差分なし → no-op
⏭ 14-dev-prod-fix-not-hostage (id=4188474) 差分なし → no-op
⏭ 16-dev-shared-todo-txt-human-ai (id=4168967) 差分なし → no-op
合計 PUT=0 no-op=7 新規skip=0 ファイル欠=0 不一致=0
Seven articles, zero writes.
I verified that something worked correctly by looking at a log where nothing happened.
Before even that, the sync had a dry-run mode — do everything except the write, and print what it would have sent. Idempotence is a property you want to demonstrate before the first PUT exists in your history, not after.
Normalisation is not preprocessing. It's the definition of "different."
The comparison only means something if you've decided what counts as a difference, and this is where the design earns its keep.
Mine is deliberately thin — line endings, trailing whitespace, runs of blank lines, and the quotes the platform strips from the frontmatter title:
def normalize(s):
s = s.replace("\r\n", "\n")
s = "\n".join(l.rstrip() for l in s.split("\n"))
s = re.sub(r"\n{3,}", "\n\n", s)
s = re.sub(r'(?m)^(title:\s*)"(.*)"\s*$', r"\1\2", s)
...
Then the dry run found something I would never have predicted.
The platform adds language labels to unlabelled code fences. I write a bare `, it looks at the contents, decides that's shell or plaintext or conf, and stores it that way. So the version I sent and the version it returns are permanently different, through no edit by any human.
The consequence isn't cosmetic. Every sync would see a difference, PUT the article, get back the labelled version, and see a difference again on the next run. A permanent write loop against published articles, driven entirely by an artifact of the comparison.
The fix is one line — ignore the fence label when comparing:
`python)[\w.#+-]*", r"\1", s)
s = re.sub(r"(?m)^(`
`
Note what it does not do. It only affects comparison. What gets sent is still exactly what's in the repo, so the platform keeps applying its own labels and I never fight it. Two safeguards for one rule.
After that, the run went back to PUT=0 no-op=7. Zero churn, verified the same way as before: by nothing happening.
This is the part I'd generalise. Normalisation feels like a preprocessing detail, something you sprinkle on before a diff. It isn't. Every rule you add or omit is you deciding what "changed" means, and if you never decide, your comparison will happily measure something you didn't intend — in my case, the platform's syntax highlighter.
When you can't get the proof, remove the target instead
I run a second mirror to a different platform, and that one has a native git integration — I push, it publishes. No API calls, so no comparison, so no no-op log. The canary-zero trick simply isn't available.
Rather than pretend it was fine, I changed what the initialisation protected: connect the integration with zero articles in the repository, then add them back one at a time.
If there's nothing to overwrite, the first run can't overwrite anything. I couldn't prove the machinery was safe, so I made the blast radius empty and grew it deliberately — one article, then another, then a few.
Same goal, different mechanism, because the mechanisms have different properties. I think that's the more useful takeaway than either trick on its own: decide what the initialisation has to guarantee, then pick the technique that can actually deliver it here.
The other edge of the same knife
One honest postscript, because a sync this decisive cuts both ways.
It sends whole articles, which means the publish flag in my repo is authoritative too. I once pushed a stale local copy of a published post that still said published: false, and the sync faithfully unpublished a live article.
That's not a separate bug. It's the same property that makes the design work — the repo is the truth and the platform is made to match it — applied to an input I hadn't checked. The more self-healing your sync is, the more precisely it will reproduce a mistake in its input. Mine now has a documented pre-push check for exactly this.
Takeaways
Initialise from the live system, so the first run is a no-op by construction. Then treat that no-op as your proof that the comparison is correct — it's the only test you get to run before the writes start.
Write the dry-run mode first. Idempotence demonstrated after your first production write is a postmortem, not a test.
Treat normalisation as the definition of "different," not as cleanup. Ask what the platform silently changes about your content, because whatever that is will look like an edit forever.
And if the proof isn't available for your mechanism, shrink what can be damaged until the first run is safe by arithmetic instead.
What does your deploy target quietly rewrite after you hand it your content?
── Hideyuki Mori (Ayane International) 🔗 hideyuki-mori.com
Top comments (5)
Starting with a sync that intentionally does nothing is a smart discipline. Idempotence is much easier to prove before side effects exist than after the system has already learned to mutate production state.
Thanks — and the phrasing about side effects is sharper than mine.
The part I would add from doing it: seeding from live was only available because the sync is one-way. The platform is the sole writer of its own state, so copying it is a legitimate starting point rather than a guess. If writes had been flowing both directions, "seed from the other side" would just be picking a winner before I understood the conflict, and the first run would have been a decision rather than a measurement.
It also has a cost worth naming, since I did not spell it out in the piece. Seeding from live means the first run cannot catch an error on my side — the repository could be wrong and the no-op would look identical. What it proves is that my fetching, normalisation and comparison agree with the platform, which is exactly the machinery I did not trust yet. Correctness of the content is a separate question, and it needed a separate check.
That one-way detail is important. Seeding from live is measurement only when the other side is clearly the source of truth. In a two-way sync, the same move becomes conflict resolution disguised as setup, which is much riskier.
That's the sharper statement of it, and it exposes something my version was hiding: which side is the source of truth isn't a property of the pair, it's a property of the field.
The same two systems here are one-way in both directions at once. The content flows one way — I write in the repository, push, and the platform renders what it's given. The state flows the other way — whether a thing is actually published, and the URL it landed on, are facts only the platform can produce. So seeding from live is legitimate for state and would be destructive for content, since it would overwrite drafts with whatever happens to be public. A sync that is unambiguously one-way at the system level can still contain your two-way trap inside it, if you seed the wrong field from the wrong end.
I got a clean demonstration of that today, in the direction I hadn't worried about. My repository said an article was published; the platform returned 404. The repository wasn't lying about the content, which was correct and ready, but its claim about state was simply wrong — the article had been marked published locally and then rejected at registration by a rate limit nobody's code knew about. Every local signal agreed with itself.
Had I treated the repository as authoritative for state there — reasonable-sounding, since it's authoritative for everything else in that pair — I'd have been doing exactly what you describe: picking a winner before understanding the conflict, and calling it setup. What made it a measurement instead was that the one field flowing the other way was the one I hadn't given myself permission to write.
Field-level source of truth is the clean way to say it. Content, publication state, URL, timestamps, and platform errors may all belong to different authorities. A safe sync needs to know which authority owns each field before it decides whether copying is measurement or mutation.