<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Devto on Tiziano Basile | Software Engineer</title><link>https://bstz.it/tags/devto/</link><description>Recent content in Devto on Tiziano Basile | Software Engineer</description><generator>Hugo -- gohugo.io</generator><language>en</language><copyright>Tiziano Basile</copyright><lastBuildDate>Sat, 18 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://bstz.it/tags/devto/index.xml" rel="self" type="application/rss+xml"/><item><title>Crossposting without the copy-paste: a GitHub Action from Hugo to dev.to</title><link>https://bstz.it/p/crossposting-without-the-copy-paste-a-github-action-from-hugo-to-dev.to/</link><pubDate>Sat, 18 Apr 2026 00:00:00 +0000</pubDate><guid>https://bstz.it/p/crossposting-without-the-copy-paste-a-github-action-from-hugo-to-dev.to/</guid><description>&lt;p&gt;My blog runs on Hugo. I also recently discovered I can cross-post to &lt;a class="link" href="https://dev.to" target="_blank" rel="noopener"
&gt;dev.to&lt;/a&gt;, because that&amp;rsquo;s where a lot of the conversation actually happens. For a while, my workflow was the obvious one: write the post, publish it here, open dev.to, paste it in, fix the frontmatter, fix the links, hit publish.&lt;/p&gt;
&lt;p&gt;It worked. I also stopped doing it after the second post.&lt;/p&gt;
&lt;p&gt;Copy-paste friction is the kind of chore that silently kills a publishing habit. So I did what most developers do when a task becomes repetitive: I automated it. And then I kept refining the automation until it was worth giving away.&lt;/p&gt;
&lt;p&gt;The result is &lt;a class="link" href="https://github.com/basteez/hugo-to-devto-action" target="_blank" rel="noopener"
&gt;&lt;code&gt;basteez/hugo-to-devto-action&lt;/code&gt;&lt;/a&gt;, now &lt;a class="link" href="https://github.com/marketplace/actions/hugo-to-dev-to-crosspost" target="_blank" rel="noopener"
&gt;published on the GitHub Marketplace&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="what-it-does"&gt;What it does
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s a composite GitHub Action. On every push to your blog repo, it looks at what changed in the push range, finds new Hugo posts under your &lt;code&gt;content/post&lt;/code&gt; directory, and creates a corresponding &lt;strong&gt;draft&lt;/strong&gt; on dev.to for each one that is marked &lt;code&gt;draft: false&lt;/code&gt; locally.&lt;/p&gt;
&lt;p&gt;Key behaviours, chosen deliberately:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Drafts, not published posts.&lt;/strong&gt; The action never hits publish on dev.to for you. It mirrors content, but you stay in control of when it goes live on the other platform. This is intentional: dev.to has its own audience, its own timing, and its own editorial choices (cover image, tags, canonical URL). I want a starting point there&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dedup by title.&lt;/strong&gt; Before creating a draft, the action queries your dev.to account and skips anything whose title already exists. That means you can safely re-run on any push without ending up with duplicates, and editing a post on dev.to directly doesn&amp;rsquo;t get overwritten.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Push-range aware.&lt;/strong&gt; It uses &lt;code&gt;git diff&lt;/code&gt; across &lt;code&gt;github.event.before..github.event.after&lt;/code&gt; to find what actually changed, rather than scanning the whole repo every time. New posts get mirrored; edits to old posts don&amp;rsquo;t trigger spurious drafts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stateless.&lt;/strong&gt; There is no database, no cache, no state file in your repo. dev.to is the source of truth for &amp;ldquo;does this already exist?&amp;rdquo; If you ever want to start fresh, you can.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="using-it"&gt;Using it
&lt;/h2&gt;&lt;p&gt;The minimum viable workflow is about fifteen lines:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;span class="lnt"&gt;21
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Crosspost to dev.to&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&lt;/span&gt;&lt;span class="nt"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;push&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;branches&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="l"&gt;main]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&lt;/span&gt;&lt;span class="nt"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;read&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&lt;/span&gt;&lt;span class="nt"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;crosspost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;runs-on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;ubuntu-latest&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/checkout@v4&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;fetch-depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;basteez/hugo-to-devto-action@v1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;devto-api-key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;${{ secrets.DEVTO_API_KEY }}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;before&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;${{ github.event.before }}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;${{ github.event.after }}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Two things to watch for:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;fetch-depth: 0&lt;/code&gt; is not optional.&lt;/strong&gt; The default shallow checkout doesn&amp;rsquo;t contain the &lt;code&gt;before&lt;/code&gt; SHA of a real push, so &lt;code&gt;git diff&lt;/code&gt; has nothing to compare against. If you leave this out, the action will fail quickly and loudly. That failure mode is well understood; don&amp;rsquo;t go hunting for a mysterious bug.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;DEVTO_API_KEY&lt;/code&gt; lives in repo secrets.&lt;/strong&gt; Grab a Personal API Key from dev.to (Settings → Extensions → DEV Community API Keys) and add it as &lt;code&gt;DEVTO_API_KEY&lt;/code&gt; under your repo&amp;rsquo;s secrets. The action forwards it into the child process; it&amp;rsquo;s never logged.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If your posts live somewhere other than &lt;code&gt;content/post&lt;/code&gt;, pass &lt;code&gt;post-dir:&lt;/code&gt; with the path. If you want to see what would happen without actually creating drafts, pass &lt;code&gt;dry-run: 'true'&lt;/code&gt;. That&amp;rsquo;s the whole surface area.&lt;/p&gt;
&lt;h2 id="a-word-on-versioning"&gt;A word on versioning
&lt;/h2&gt;&lt;p&gt;I went with the GitHub convention of three pin styles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;@v1&lt;/code&gt; — a moving tag that tracks the latest non-breaking release. Bug fixes and additive features land automatically. This is what most people want.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@v1.x.y&lt;/code&gt; — a full semver pin if you need an immutable reference.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@&amp;lt;commit-sha&amp;gt;&lt;/code&gt; — a 40-character SHA for organisations that mandate commit-level pinning.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;code&gt;v1&lt;/code&gt; tag will &lt;strong&gt;never&lt;/strong&gt; be force-updated to a breaking release. Breaking changes bump to &lt;code&gt;@v2&lt;/code&gt;. That way, the convenient default is also the safe default.&lt;/p&gt;
&lt;h2 id="why-bother-extracting-it"&gt;Why bother extracting it
&lt;/h2&gt;&lt;p&gt;This started life as a script in this blog&amp;rsquo;s own repo. It worked fine there. Extracting it into a standalone action was more work than copy-pasting a script into other Hugo repos would have been.&lt;/p&gt;
&lt;p&gt;I did it anyway, for three reasons.&lt;/p&gt;
&lt;p&gt;First, &lt;strong&gt;separation of concerns.&lt;/strong&gt; The crosspost logic has nothing to do with my blog content. Keeping them in the same repo was mixing &amp;ldquo;what I publish&amp;rdquo; with &amp;ldquo;how I publish it,&amp;rdquo; and every time I touched one, I had to reason about the other.&lt;/p&gt;
&lt;p&gt;Second, &lt;strong&gt;reuse.&lt;/strong&gt; I&amp;rsquo;m not the only person running a Hugo blog who&amp;rsquo;d like to mirror to dev.to. If the tool exists in a shareable form, other people can use it without forking my blog.&lt;/p&gt;
&lt;p&gt;Third, and this is the part I didn&amp;rsquo;t expect, &lt;strong&gt;extraction forces clarity.&lt;/strong&gt; When code is hiding inside your own repo, you get to take a lot of context for granted. The directory layout, the branching model, the workflow triggers, the assumptions about what&amp;rsquo;s already installed. Turning it into a public action meant writing all of that down as explicit inputs, documented defaults, and failure modes. The code ended up smaller and sharper.&lt;/p&gt;
&lt;p&gt;That last point keeps showing up in my work, and I think it&amp;rsquo;s worth naming: the act of making something reusable is often more valuable for the original project than for anyone who&amp;rsquo;ll reuse it.&lt;/p&gt;
&lt;h2 id="whats-next"&gt;What&amp;rsquo;s next
&lt;/h2&gt;&lt;p&gt;A few directions I&amp;rsquo;m considering, in rough priority order:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Optional publish mode, for people who genuinely do want one-click mirroring.&lt;/li&gt;
&lt;li&gt;Better handling of images and relative links, which is currently the biggest rough edge.&lt;/li&gt;
&lt;li&gt;Support for other destinations (Hashnode, Medium), though I&amp;rsquo;d rather keep this action focused and spin up siblings than turn it into a universal crossposter.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If any of this is useful to you, grab it from the &lt;a class="link" href="https://github.com/marketplace/actions/hugo-to-dev-to-crosspost" target="_blank" rel="noopener"
&gt;Marketplace&lt;/a&gt; or reference it directly as &lt;code&gt;basteez/hugo-to-devto-action@v1&lt;/code&gt;. Bug reports and PRs are welcome on &lt;a class="link" href="https://github.com/basteez/hugo-to-devto-action" target="_blank" rel="noopener"
&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;And if this post shows up on my dev.to profile before I get around to publishing it there, you&amp;rsquo;ll know the action works.&lt;/p&gt;</description></item></channel></rss>