DEV Community

Takashi Abe
Takashi Abe

Posted on Originally published at zenn.dev

I Let Claude Code Grill Me, Then Built a Zenn-to-dev.to Crossposting Pipeline

I built a pipeline that translates the articles I write on Zenn into English and publishes them on dev.to too. I translate locally with Claude Code, and when I push to main, GitHub Actions publishes, updates, or unpublishes the article on dev.to.

My starting point was asherish's "Blog repository setup." Its setup, which bridges Zenn and dev.to with GitHub Actions, was a direct reference. But by the time I finished, my setup had made different calls from the original in three places:

  • No file recording dev.to article IDs; match by canonical_url every time
  • Only the original Japanese article holds the publication state
  • Detect a stale translation using a hash of the original article

The result is a setup where CI writes nothing back to the repository, and the only state lives on dev.to. In this post, I'll walk through the order in which I made those decisions, and how I used Claude Code along the way.

I settled the requirements by letting Claude Code ask me 30+ questions

The request I first gave Claude Code was just these three lines (originally in Japanese):

I want to create a GitHub Actions workflow that, when an article is added to articles/ for zenn.dev and becomes "published", posts a translated version of it to dev.to.
Use this as a reference.
Don't decide anything unclear by guessing; check everything with me.

I ran this request through grill-with-docs from Matt Pocock's skill collection for Claude Code. As the name suggests, it's a skill that grills the user with questions, closing off design branches one at a time. It doesn't ask everything at once; it only asks a batch of questions whose prerequisites are already settled. For example, until "where does translation happen?" is decided, it won't ask "where do we store the translation API key?"

Here are some of the questions I actually got:

Question Claude's recommendation My answer
Should the 18 existing published articles be crossposted too? No No
If the original is edited after publishing, update dev.to too? Yes Yes
Post to dev.to as a draft, or publish immediately? Draft Publish immediately
Where should images be served from (the repo is private)? Make the repo public Don't bring images
Translate in CI, or locally? CI Local Claude Code

For several questions, my answer differed from the recommendation. Drafts, for instance, were recommended "so you can review the machine translation once." But since I'd decided to translate locally, the review already happens before commit. That "where does translation happen?" question was one Claude added later, after reading the reference article. The reference didn't translate in CI, which contradicted my request ("translate and post with Actions"). That one question decided half of the design that followed.

Over five rounds, the questions went past 30. Each time an answer was settled, Claude added the terms to CONTEXT.md. That file pins down what words like "original article," "English version," "publish," and "crosspost" mean in this repository. You could call it a ubiquitous language, in domain-driven design terms. For decisions that would be hard to reverse later, it also recorded two ADRs (Architecture Decision Records: short documents capturing a decision and its reasoning).

As an aside, I'm writing a Zenn book about ADRs. I'll publish it once it's done.

ℹ️ grill-with-docs attaches a recommended answer to each question. You can keep going by just answering "sounds good" to the recommendation, so it isn't as tiring as the question count suggests. Conversely, if you don't want to accept recommendations blindly, make sure to read the reasoning attached to each one.

Translation stays out of CI: I review locally, then commit

My original, rough request was "translate and post with Actions," but in the end I took translation out of CI. The deciding factor was choosing to publish immediately on dev.to. If CI calls an LLM API to translate, English text that no human has ever read goes straight to publication.

So I split the roles like this:

flowchart LR
  A[Write the original article] --> B[/translate into English/]
  B --> C[Read the English version and commit]
  C --> D[Push to main]
  D --> E[Actions crossposts to dev.to]

Translation happens through the Claude Code skill /translate <slug>. The skill writes the translated body, and a script called translate-apply.ts converts Zenn syntax, adds frontmatter, and writes the result to articles_en/<slug>.md. The skill doesn't commit. I'm the one who reads the English version and commits it, and that's the only review checkpoint.

This way, I can safely use Claude Code on my subscription without setting up any translation API key. And there's no extra usage cost. The tradeoff is that if I forget to translate, the article just stays un-crossposted. I decided to catch that with the "on hold" warning described later.

Don't store dev.to article IDs; look them up by canonical_url

The reference setup stores the mapping between slugs and dev.to article IDs in .devto-mapping.json, and Actions commits that file back. From the second run onward, it uses that ID to update (PUT).

This approach caused me two problems. First, the Actions commit collides with my local main. Unless I pull after every push, my next push gets rejected. Second, what if the post (POST) to dev.to succeeds but committing the mapping fails? On the next run, the ID isn't found, so the same article gets posted again.

So in my setup, I chose not to keep a mapping at all. On every crosspost, it fetches my article list from the dev.to API and looks for the one whose canonical_url matches the URL of the original Zenn article. canonical_url is the URL that tells search engines "the original of this article is over here." Since every crossposted article always has one set, it works as a matching key.

sequenceDiagram
  participant GA as GitHub Actions
  participant API as dev.to API
  GA->>API: GET /api/articles/me/all
  API-->>GA: Article list (including unpublished)
  GA->>GA: Match against originals by canonical_url
  GA->>API: POST or PUT (only articles with changes)

Whether this works depends on how the list API on the dev.to side behaves. If unpublished articles weren't included in the list, an article that had been unpublished once would be judged "not on dev.to" when republished, and get posted twice. The documentation alone wasn't enough, so I had Claude read the source of Forem, which runs dev.to:

  • /api/articles/me/all also returns unpublished articles (the docs say "Unpublished articles will be at the top of the list")
  • The response includes published, body_markdown, tag_list (an array), and canonical_url
    • Source: app/views/api/v1/articles/me.json.jbuilder
  • body_markdown is stored exactly as sent; no frontmatter gets added

That told me the diff check could also be done with a single list fetch. The costs are one list fetch per crosspost, and that if someone manually edits the canonical_url in the dev.to UI, matching breaks. The latter is written down in an ADR as an operational rule.

Only the original Japanese article holds the publication state

In the reference setup, the English version's frontmatter also has published, and CI verifies that it matches the Japanese version. If they don't match, publishing stops.

In the option I chose, the English version doesn't have published at all. Actions always reads the original article's published and makes dev.to match it. If there's only one source to begin with, there's nothing to check for mismatches. Setting the original back to published: false is all it takes to unpublish on dev.to as well.

As a result, the English version's frontmatter has just three fields:

articles_en/.md

---
title: 'Why "Harness": a note'
tags:
  - ai
  - mcp
source_hash: edf3ae74a6a6...
---
Enter fullscreen mode Exit fullscreen mode

What happens to each article is decided as shown below. Only "slugs that have an English version" are processed. The 18 existing articles have no English version, so they naturally fall out of scope without any exclusion list.

flowchart TD
  A[Has English version] --> B{Original is published}
  B -- Yes --> C{Exists on dev.to}
  B -- Unpublished or deleted --> D{Published on dev.to}
  C -- No --> E[POST and publish immediately]
  C -- Yes --> F{Content changed}
  F -- Yes --> G[Update with PUT]
  F -- No --> H[Do nothing]
  D -- Yes --> I[Unpublish with PUT]
  D -- No --> H

The diff check compares four things: title, body, tags, and publication state. The reference setup PUT every published article on every push, but that needlessly changes the updated timestamp on dev.to as well. It also pushes you closer to dev.to's rate limits (by default, 9 article creations and 30 updates per 30 seconds).

Catch stale translations with a hash of the original

Sometimes I fix the original after publishing and forget to regenerate the English version. To catch this, I record the original article's hash at the time the English version was made, as source_hash in the English version. At crosspost time, it computes the current original's hash and warns if they don't match.

So what should that hash be computed from? The first thing that comes to mind is a hash of the whole original file. But then just flipping published: false to true changes the hash. If you prepare the translation ahead of time and then publish, you'd get a "translation is stale" warning every single time.

So I narrowed the hash input down to the title and body:

$$
h = \mathrm{SHA256}\left(\mathrm{JSON}({\,\mathit{title},\ \mathit{body}\,})\right)
$$

Changing published, topics, or emoji leaves $h$ unchanged; it only changes when the body or title changes. The tests state exactly this property:

scripts/lib/hash.test.ts

it("published を切り替えてもハッシュは変わらない", () => {
  const published = original.replace("published: false", "published: true");

  expect(computeSourceHash(published)).toBe(computeSourceHash(original));
});
Enter fullscreen mode Exit fullscreen mode

A stale English version is still crossposted as-is, with a warning. If crossposting stopped instead, fixing even a single typo would leave dev.to un-updated until the translation was regenerated.

Pin down Zenn syntax conversion with tests

dev.to doesn't understand Zenn's own syntax, so conversion is needed.

Zenn On dev.to
:::details タイトル {% details タイトル %} … {% enddetails %}
:::message (alert) A blockquote prefixed with ℹ️ (⚠️)
Fence info ts:src/index.ts A bold **src/index.ts** right before the code
@[card](URL) A plain link
@[youtube](ID) etc. {% embed URL %}
![alt](/images/...) A note saying "see the original Zenn article for the image"

Images are replaced with a note because the repository where I manage my Zenn articles is private, so dev.to can't fetch images from it. Making the repository public was an option, but it would expose unpublished drafts too, so I passed on it.

The conversion isn't left to Claude Code; it's a regex-based script. With an LLM, there's no guarantee the same input produces the same output every time. A deterministic program or script lets you pin its behavior down with tests.

The trickiest part of the conversion was the constraint that nothing inside code blocks gets converted. Sometimes, like in this very post, I write code examples that explain Zenn syntax itself:

```md
:::details これは記法の説明なので、変換してはいけない
:::
```
Enter fullscreen mode Exit fullscreen mode

So I went with a somewhat fiddly mechanism: before conversion, each code block is stashed away and replaced with a one-line placeholder (a number wrapped in the Unicode Private Use Area characters U+E000 and U+E001), and restored after conversion finishes.

Stash-and-restore code (excerpt)

scripts/lib/convert.ts

export function convertZennToDevto(markdown: string, options: ConvertOptions): string {
  const { text, blocks } = stashCodeBlocks(markdown);
  const converted = [
    (t: string) => convertLocalImages(t, options.originalUrl),
    convertEmbeds,
    convertDetails,
    convertMessages,
  ].reduce((acc, convert) => convert(acc), text);
  return converted.replace(PLACEHOLDER, (_m, prefix: string | undefined, i: string) =>
    prefix ? quoteLines(prefix, blocks[Number(i)]) : blocks[Number(i)],
  );
}
Enter fullscreen mode Exit fullscreen mode

This approach has a pitfall. Since :::message is converted into a blockquote, the placeholder line inside it also starts with >. But when the placeholder is restored, the second and subsequent lines of the code block don't have >. The blockquote gets cut off mid-code. On restore, it looks at the leading > and re-applies it to every line of the code.

What you didn't decide shows up during implementation

Writing tests first, gaps surfaced that even 30+ questions hadn't closed. No matter how good the architect on the team is, design can't define every implementation problem up front. Some things you only find out by actually implementing them, and that happens in the real world. It happens just as naturally in AI-driven development.

The biggest one was how to handle "on hold." During the grilling phase, I'd decided that "a published article without an English version gets its crosspost put on hold, with a warning." But once I wrote the tests, all 18 existing articles matched that condition. As-is, every push would line up 18 warnings.

Claude Code stopped implementing and came back with four options:

  1. Warn only about articles changed in that push
  2. Keep an exclusion list
  3. Don't warn
  4. Warn about everything every time (as agreed)

I chose 1. It takes the diff against the previous commit with git diff, and warns only about changed originals that have no English version.

After implementation, I also had a review agent look it over. Of its 10 findings, I fixed 7. The main four:

  • If a force push removes the previous commit, git diff fails and the whole job stops. Since the warning is a supporting feature, crossposting now continues even if it fails
  • A broken YAML in just one original article stopped crossposting for every article. It's now treated as a per-article error
  • If dev.to saves tags in a different order or changes the trailing newline, articles with no changes get updated every time. Those two are now excluded from the comparison
  • Zenn lets you nest :::message inside ::::details, but the outer one wasn't being converted

In the end there were 41 tests, with 99% line coverage and 93% branch coverage. Things with small impact, or that recover on a rerun, such as retrying on 5xx errors, were skipped, with the reasons written down.

On the first crosspost, the tags silently vanished

I tried the first crosspost with ""Is That a Harness Too?" Making Sense of Harness Engineering for AI Agents (Japanese original)." I committed the English version, merged the PR, and the push to main triggered Actions. The article is published as ""Is That a Harness Too?" Making Sense of Harness Engineering for AI Agents." The canonical_url pointed to the original Zenn article, and no Zenn syntax was left in the body.

But when I actually ran it, there wasn't a single tag on it. The English version's frontmatter listed four: ai, agents, llm, and architecture. The API didn't return an error, so there was nothing in the Actions logs to tip me off.

The cause was how the tags were sent. The dev.to API docs say tags is "a comma-separated string." The first implementation followed that and sent "ai,agents,llm,architecture". But reading Forem's controller, it only accepts an array:

app/controllers/concerns/api/articles_controller.rb

allowed_params = [
  :title, :body_markdown, :published, :series,
  :main_image, :canonical_url, :description, { tags: [] },
  # ...
]
Enter fullscreen mode Exit fullscreen mode

When a key is declared as { tags: [] }, Rails strong parameters silently drop a string value instead of raising an error. I'd had Claude read the source to verify the list API, yet blindly trusted the docs for the API that sends articles. Honestly, though, this one's on the docs. Leaning on Claude Code this time is what led me to read the source code at all; a human developer would normally have no choice but to trust the docs unless there were prior reports. What a pain.

This bug had one more annoying side effect. Since the tags on dev.to stayed empty, the diff check saw "tags differ" every time. Even with nothing changed, a PUT would keep firing on every push. I fixed it to send tags as an array and changed the test expectations to arrays too. Thanks to that, the tags are now assigned properly.

Looking back

In one sentence, the setup keeps translation in human hands locally, and leaves CI with just one job: mirroring the original article's publication state to dev.to. Since CI writes nothing back, there's no mapping file, no bot commits, and no collisions with my local main.

Starting from the reference setup was the right call. Having that foundation is exactly what let me ask, for each decision like "commit a mapping file" or "keep published in two places," whether it fit how I work. And the grilling skill is what laid out those questions without missing any.

If you're thinking about running Zenn and dev.to side by side the same way, I'd suggest deciding two things first: "where does translation happen?" and "where does the dev.to-side state live?" The rest of the decisions should get much easier after that.

Top comments (0)