DEV Community

mrnoyy
mrnoyy

Posted on

Publishing to Dev.to from GitHub Actions with Zero Dependencies

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
Enter fullscreen mode Exit fullscreen mode

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',
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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}`;
}
Enter fullscreen mode Exit fullscreen mode

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)