I write posts in markdown, commit them, and they show up on Dev.to. No dashboard, no copy-paste, no npm install. Here is the whole thing.
Why zero dependencies
The pipeline does three things: read a file, hash it, send JSON over HTTP. Node 18+ has fs, crypto, and fetch built in. Adding axios, gray-matter, and dotenv would mean a node_modules restore on every CI run to save maybe forty lines of code.
My whole runner step is now:
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: node src/index.js publish
No install step at all. The job finishes in about fifteen seconds.
The three things that broke first
1. The auth header is not Bearer
Almost every API I had touched before uses Authorization: Bearer <token>. Dev.to does not. The key goes in a header literally named api-key:
headers: {
'api-key': apiKey,
'Content-Type': 'application/json',
accept: 'application/vnd.forem.api-v1+json',
}
I lost twenty minutes to 401s before reading the docs properly rather than assuming.
2. Retry-After comes back in two different formats
When you hit the rate limit you get a 429 with a Retry-After header. Sometimes it is a number of seconds. Sometimes it is a full HTTP-date string like Fri, 31 Jul 2026 07:00:00 GMT.
My first implementation was parseInt(header, 10). On the date format that returns 31, so the retry fires half a minute later, hits the limit again, and the loop burns through its attempts for nothing.
function retryAfterMs(header, fallbackMs = 30000) {
if (!header) return fallbackMs;
const raw = String(header).trim();
if (/^\d+$/.test(raw)) return Number(raw) * 1000;
const when = Date.parse(raw);
if (!Number.isNaN(when)) {
const diff = when - Date.now();
return diff > 0 ? diff : fallbackMs;
}
return fallbackMs;
}
Handle both, and always keep a fallback for the case where the header is missing or garbage.
3. Tags are sanitized silently
Dev.to strips everything that is not a lowercase letter or a digit. next-js becomes nextjs. You get a 200 back and no warning at all.
That matters if you hash your content to decide whether to update a post, like I do. The local file says next-js, the remote says nextjs, so the hash never matches and every run pushes a pointless update. Normalize before hashing, not after:
const normalizeTags = (tags = []) =>
tags
.map((t) => String(t).toLowerCase().replace(/[^a-z0-9]/g, ''))
.filter(Boolean)
.slice(0, 4);
Four tags is the ceiling. The fifth is dropped, again silently.
Not creating duplicates
The API has no idempotency key, so POST /articles twice gives you two articles. I keep a small state file mapping slug to article id and a content hash:
{
"my-post": {
"id": 1234567,
"url": "https://dev.to/user/my-post-abcd",
"hash": "3f9a1c2b7e04"
}
}
New slug means POST. Known slug with a changed hash means PUT /articles/:id. Known slug, same hash, do nothing. The workflow commits that file back to the repo after each run, with contents: write permission and a path filter on the trigger so the commit does not retrigger the workflow.
Canonical URLs
This is the part that actually matters if you have your own blog. Every cross-post should carry canonical_url pointing back to your original. Without it you have published the same article on a domain with far more authority than yours, and search engines will pick that one.
I derive it from the filename so I cannot forget:
if (!post.canonical_url && CANONICAL_BASE) {
post.canonical_url = `${CANONICAL_BASE}/${post.slug}`;
}
And the script warns loudly on any post that ends up without one.
Dry run first
--dry-run prints exactly what would be sent, resolved tags and canonical included, and touches nothing. Every schema change I make gets a dry run before it gets an API call. It has caught more mistakes than any test I wrote.
The whole thing is about 250 lines across three files. If you cross-post to more than one platform, the same shape works — the state file just grows a key per platform.
Top comments (0)