<?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: sunah kim</title>
    <description>The latest articles on DEV Community by sunah kim (@pythona).</description>
    <link>https://dev.to/pythona</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%2F4170698%2F17c9c746-a38d-4e91-a5ca-2a65d7821a71.png</url>
      <title>DEV Community: sunah kim</title>
      <link>https://dev.to/pythona</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pythona"/>
    <language>en</language>
    <item>
      <title>Building a Multilingual Blog Pipeline That Auto-Posts to Zenn, dev.to, and velog</title>
      <dc:creator>sunah kim</dc:creator>
      <pubDate>Thu, 08 Oct 2026 11:54:30 +0000</pubDate>
      <link>https://dev.to/pythona/building-a-multilingual-blog-pipeline-that-auto-posts-to-zenn-devto-and-velog-246p</link>
      <guid>https://dev.to/pythona/building-a-multilingual-blog-pipeline-that-auto-posts-to-zenn-devto-and-velog-246p</guid>
      <description>&lt;h2&gt;
  
  
  Why I built this
&lt;/h2&gt;

&lt;p&gt;As a Korean engineer working in Japan, I've long wanted to publish technical articles in three languages: Japanese on Zenn, English on dev.to, and Korean on velog. Manually copy-pasting every article into three platforms was never going to happen, so I built a pipeline with a single GitHub repository as the hub — push once, and the article goes out to all three platforms automatically.&lt;/p&gt;

&lt;p&gt;I expected Zenn to be the easy part and got tripped up anyway, and velog turned out to have no official API at all. Here are the pitfalls I actually hit and how I solved them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Overall architecture
&lt;/h2&gt;

&lt;p&gt;The repo layout is simple — one directory per platform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.
├── articles/   # Zenn (auto-deployed via GitHub integration)
├── devto/      # dev.to (GitHub Actions + official API)
├── velog/      # velog (GitHub Actions + GraphQL)
└── material.md # topic backlog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I push an article, three things happen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Zenn&lt;/strong&gt;: the official GitHub integration detects changes under &lt;code&gt;articles/&lt;/code&gt; and deploys them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;dev.to&lt;/strong&gt;: a GitHub Actions workflow posts files under &lt;code&gt;devto/&lt;/code&gt; through the official REST API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;velog&lt;/strong&gt;: a workflow posts files under &lt;code&gt;velog/&lt;/code&gt; through velog's internal GraphQL API&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Pitfall 1: Zenn's GitHub integration must be started from the Zenn side
&lt;/h2&gt;

&lt;p&gt;My first stumble was Zenn itself. If you install the Zenn Connect app from the GitHub Marketplace side first, the integration never shows up in Zenn's dashboard (&lt;code&gt;zenn.dev/dashboard/deploys&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cause&lt;/strong&gt; is the direction of the flow. Zenn's integration is designed so that the OAuth authorization and repository selection happen from Zenn's settings screen. Installing only the GitHub App from the GitHub side never creates the link to your Zenn account. Zenn's docs even say explicitly: if the app is already installed and you see this screen, uninstall it first and reconnect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt; was simple: uninstall the app on the GitHub side, then redo the integration from the "Connect repository" (リポジトリを連携する) button in Zenn's settings.&lt;/p&gt;

&lt;p&gt;One more gotcha: &lt;strong&gt;commits pushed before the integration are never deployed&lt;/strong&gt;. Only pushes after the connection is established get picked up, so if you have existing articles, you need a fresh commit after connecting. An empty commit works fine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git commit &lt;span class="nt"&gt;--allow-empty&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"trigger zenn deploy"&lt;/span&gt;
git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That kicked off the first deploy and my articles appeared in the dashboard. A nice bonus: private repositories work too, so your drafts and commit history stay private while only published articles go public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 2: dev.to wants the front matter inside the body
&lt;/h2&gt;

&lt;p&gt;dev.to was the pleasant one, since there's an official API. The key point is that instead of sending title and tags as separate fields, you send &lt;strong&gt;the entire Markdown including front matter as &lt;code&gt;body_markdown&lt;/code&gt;&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://dev.to/api/articles"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"api-key: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DEVTO_API_KEY&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"article": {"body_markdown": "---\ntitle: ...\npublished: true\ntags: ...\ncanonical_url: https://zenn.dev/...\n---\n\nBody here"}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important field is &lt;code&gt;canonical_url&lt;/code&gt;. Publishing the same content on multiple sites risks being treated as duplicate content by search engines, but pointing &lt;code&gt;canonical_url&lt;/code&gt; at the Zenn original declares "this is the canonical source." Also, &lt;code&gt;published: false&lt;/code&gt; lets you post as a draft, so it's safest to test the pipeline with drafts first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 3: velog has no official API
&lt;/h2&gt;

&lt;p&gt;velog was the hard one. There is no official API, so I ended up calling the internal GraphQL endpoint used by the web frontend (&lt;code&gt;v3.velog.io/graphql&lt;/code&gt;), authenticated with the login session cookies (&lt;code&gt;access_token&lt;/code&gt; / &lt;code&gt;refresh_token&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Then I hit a baffling behavior: if you pass &lt;code&gt;null&lt;/code&gt; to the &lt;code&gt;meta&lt;/code&gt; field of the &lt;code&gt;writePost&lt;/code&gt; mutation, &lt;strong&gt;you get no error — just a &lt;code&gt;null&lt;/code&gt; result&lt;/strong&gt;. With no error message, I burned a lot of time figuring this out. The answer: pass an empty object &lt;code&gt;{}&lt;/code&gt; instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;mutation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;writePost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;is_temp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="c"&gt;# null fails silently&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;url_slug&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Token handling has its quirks too. The &lt;code&gt;access_token&lt;/code&gt; is short-lived, but if you send the expired one together with the &lt;code&gt;refresh_token&lt;/code&gt;, fresh tokens come back via &lt;code&gt;Set-Cookie&lt;/code&gt;. The workflow is supposed to pick those up and update the secrets — but the refresh token itself expires after roughly 30 days, so &lt;strong&gt;you have to manually re-grab the cookies about once a month&lt;/strong&gt;. That's the operational weak point. For testing, &lt;code&gt;is_temp: true&lt;/code&gt; saves the post as a draft, which is what I used during verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational details
&lt;/h2&gt;

&lt;p&gt;One small but important detail: the Actions workflow only processes &lt;strong&gt;newly added files&lt;/strong&gt;. Otherwise, every typo-fix push would re-post the same article:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git diff &lt;span class="nt"&gt;--name-only&lt;/span&gt; &lt;span class="nt"&gt;--diff-filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;A HEAD^ HEAD &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'devto/*.md'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All API keys and cookies live in GitHub Actions Repository Secrets, injected as environment variables in the workflow. Never commit them in plaintext — and be careful not to echo them into logs either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Start Zenn's GitHub integration &lt;strong&gt;from the Zenn side&lt;/strong&gt; ("Connect repository" button). Pre-integration commits never deploy, so push an empty commit after connecting&lt;/li&gt;
&lt;li&gt;For dev.to, put the front matter inside &lt;code&gt;body_markdown&lt;/code&gt; and use &lt;code&gt;canonical_url&lt;/code&gt; to avoid duplicate-content issues&lt;/li&gt;
&lt;li&gt;velog requires the unofficial GraphQL API; pass &lt;code&gt;{}&lt;/code&gt; (not &lt;code&gt;null&lt;/code&gt;) as &lt;code&gt;meta&lt;/code&gt;. Token expiry is the operational weak point&lt;/li&gt;
&lt;li&gt;Process only newly added files (&lt;code&gt;--diff-filter=A&lt;/code&gt;) to prevent double-posting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One last thing: the writing and translation side of this pipeline is handled by a scheduled AI agent (Claude) task. Every night it reads the topic backlog (&lt;code&gt;material.md&lt;/code&gt;), writes an article, generates all three language versions, and pushes them. I'll cover that part in detail in a future post.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>githubactions</category>
      <category>blogging</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
