DEV Community

Cover image for Four bugs between git push and a dev.to post
301ST
301ST

Posted on

Four bugs between git push and a dev.to post

Summer Bug Smash: Smash Stories 🐛🛹

TL;DR — I built a small fleet of Cloudflare Workers that auto-post every new blog article to Telegram, dev.to, Bluesky and Mastodon: one worker per network, cron + KV, all on the free plan. Getting the dev.to one right meant walking a gauntlet of four bugs — a 403 that only happened in prod, a post that broke itself, a tag dev.to flat-out rejected, and a fistful of false alarms. Here's each one, the fix, and the hardened worker at the end. (A cousin of this post went out through that very worker.)

The setup

One worker per network. Each wakes on a cron trigger, reads a tiny JSON feed of published articles oldest-first, takes the one it hasn't posted yet, sends exactly one, and records success in a KV namespace. Independent by design: own token, own schedule, own KV prefix — one breaking doesn't touch the others.

The syndication pipeline: a git push builds the site, a feed fans out to four per-network Cloudflare Workers (Telegram, dev.to, Bluesky, Mastodon), each posting to its own network
This diagram is a Mermaid block. dev.to can't render Mermaid, so I rendered it to a PNG with the same build that makes our share cards. Hold that thought — it comes back at the end.

// devto/src/index.ts — the core loop (oldest-first, KV-marked on success)
async function run(env: Env): Promise<string> {
  const feed = await fetch(env.FEED_URL, { headers: { 'cache-control': 'no-cache' } });
  if (!feed.ok) throw new Error(`feed: ${feed.status}`);
  const { posts } = (await feed.json()) as { posts: Post[] };

  // Oldest first, one per run: a new dev.to account that publishes a burst reads
  // as spam, so the backfill drips out over days.
  for (const post of posts) {
    const key = `devto:posted:${post.slug}`;
    if (await env.POSTED.get(key)) continue;

    const mirror = await fetch(`${post.url.replace(/\/$/, '')}.md`, {
      headers: { 'cache-control': 'no-cache' },
    });
    if (!mirror.ok) throw new Error(`mirror ${post.slug}: ${mirror.status}`);
    await publish(env, post, bodyFrom(await mirror.text()));

    // Written only after dev.to confirms with 201, so a failure retries on the
    // next run instead of skipping the article silently.
    await env.POSTED.put(key, new Date().toISOString());
    return `posted ${post.slug}`;
  }

  return 'nothing new';
}
Enter fullscreen mode Exit fullscreen mode

🐛 Bug 1 — the 403 that only happened in production

Symptom. The cron looked like it never fired: nothing showed up on dev.to, no error in the feed.

Red herring. I spent an hour on the cron trigger and KV — the "obvious" suspects for "nothing posted."

Root cause. Cloudflare Workers send no User-Agent by default, and Forem 403s a request without one. Locally I got 201 Created; from the Worker, a silent 403. The cron had fired — the POST was just rejected.

   const response = await fetch('https://dev.to/api/articles', {
     method: 'POST',
-    headers: { 'content-type': 'application/json', 'api-key': env.DEVTO_API_KEY },
+    headers: {
+      'content-type': 'application/json',
+      accept: 'application/json',
+      'user-agent': '301sh-devto/1.0 (+https://301.sh)',
+      'api-key': env.DEVTO_API_KEY,
+    },
     body: JSON.stringify({
Enter fullscreen mode Exit fullscreen mode

Lesson. "Works on my machine" had a new flavour: the runtime differed, not the code. A Worker is not curl — it omits headers curl sends for you.


🪲 Bug 2 — I broke my own post

Symptom. The syndicated article rendered as one giant code block on dev.to: the first few lines were fine, then everything after — every paragraph, heading and list, right down to the footer — was swallowed into grey monospace.

Root cause. My articles carry Mermaid diagrams as fenced blocks:

```mermaid
flowchart LR ...
```
Enter fullscreen mode Exit fullscreen mode

Forem's Markdown never closed that fence the way I expected, so everything downstream got swallowed into the block. (A second papercut rode along: dev.to renders a lone \n as a <br>, so my 90-column hard-wrapped source came out ragged.)

The fix. In the syndication worker, strip Mermaid blocks before sending, and unwrap hard-wrapped paragraphs while preserving fences, tables and headings (the outer fence here is four backticks — the code contains a literal `mermaid):

 function bodyFrom(mirror: string): string {
   const sep = mirror.indexOf('\n---\n');
-  const body = sep === -1 ? mirror : mirror.slice(sep + 5);
-  return body.replace(/\]\(\//g, '](https://301.sh/').trim();
+  const raw = (sep === -1 ? mirror : mirror.slice(sep + 5))
+    .replace(/\]\(\//g, '](https://301.sh/')
+    .replace(/^```mermaid[\s\S]*?^```[ \t]*$/gm, '')
+    .replace(/\n{3,}/g, '\n\n');
+  return unwrap(raw).trim();
 }
Enter fullscreen mode Exit fullscreen mode

Lesson. Rendering is a contract, and I'd assumed a clause the other side never signed. (This is also Exhibit A for the proposal at the bottom.)


🐞 Bug 3 — the tag dev.to wouldn't take

Symptom. One of the first articles I syndicated was tagged bulk-redirects. dev.to answered 422 Unprocessable Entity and refused the whole post. Drop that one tag and the identical request published fine.

Red herring. A 422 is loud but not specific — it told me the tags were unprocessable, not why. dev.to's famous tag rule is the four-tag cap, so that's where I went first: counting tags, trimming the webdev filler, reordering. All beside the point — I was never over the limit.

Root cause. dev.to tags are alphanumeric only. Forem validates every tag name against /\A[[:alnum:]]+\z/ and rejects the rest as "prohibited characters," so a hyphen is fatal: bulk-redirects isn't a tag with a cosmetic flaw, it isn't a legal tag at all. My Telegram bot had already hit the same wall from the other side — Telegram's hashtag parser stops at the hyphen and renders #bulk with a stray -redirects trailing it. Same tag, two platforms, two ways to break.

The fix. Strip each tag down to bare alphanumerics before sending, so bulk-redirects becomes bulkredirects:

 function devtoTags(tags: string[]): string[] {
   const clean = tags
-    .map((t) => t.toLowerCase())                        // illustrative: sends `bulk-redirects` as-is → dev.to 422
+    .map((t) => t.toLowerCase().replace(/[^a-z0-9]/g, ''))
     .filter(Boolean);
   if (!clean.includes('webdev')) clean.push('webdev');
   return clean.slice(0, 4);
 }
Enter fullscreen mode Exit fullscreen mode

This strip was in from the worker's first commit — I met the 422 while building it, so the - line above is a reconstruction of the naive version, to show what dev.to choked on.

Lesson. Every platform keeps a private definition of what a tag may contain, and none of them mention it until you cross the line. When one innocent string breaks two integrations, stop blaming the platforms — that's your data meeting the real world.


🐛 Bug 4 — five false alarms before lunch (coda)

Not one bug — a debugging reflex that manufactured four fake ones. After each deploy I checked the live URL immediately and "found" a bug: a 404 for a page that existed, HTML pointing at a CSS file that was already gone, a stale sitemap. Every one was Cloudflare's edge still propagating — tens of seconds where the old build and the new one coexist.

The fix was a habit, not a patch: wait on a condition, never conclude from the first request.

# not "curl once and panic" — poll until the new thing is actually served
until curl -s "https://301.sh/new-page/" | grep -q "expected marker"; do sleep 3; done
Enter fullscreen mode Exit fullscreen mode

Lesson. A first response after a deploy is not evidence. The edge is allowed to lie for a minute; your test shouldn't.


The trophy — the worker, hardened

Every fix above lives in one file now. Copy it, point it at your feed, and syndicate to DEV from a Cloudflare Worker without walking the same gauntlet.

The full worker — every fix in one file:

/**
 * Publishes 301.sh articles to dev.to (dev.to/301st), one per scheduled run.
 *
 * Unlike the Telegram bot, which posts a link and lets Telegram build the card,
 * dev.to needs the whole article. So this takes the body from the Markdown
 * mirror the site already generates for agents (`/<slug>.md`), rewrites the
 * site-relative links to absolute ones, and posts it with a `canonical_url`
 * back to 301.sh. That canonical tag is the entire point: syndication adds a
 * platform and a backlink without the copy competing with the original in
 * search.
 */
interface Env {
  POSTED: KVNamespace;
  FEED_URL: string;
  DEVTO_API_KEY: string;
}

interface Post {
  slug: string;
  title: string;
  description: string;
  url: string;
  image: string;
  tags: string[];
}

/**
 * Undo the source's ~90-char hard wrap by joining wrapped lines within a block.
 * dev.to renders a lone newline as a <br>, so the wrap has to go; code fences,
 * tables, headings and blockquotes keep their own line structure.
 */
function unwrap(md: string): string {
  const out: string[] = [];
  let fence = false;
  let buf: string | null = null;
  const flush = () => {
    if (buf !== null) out.push(buf);
    buf = null;
  };
  for (const line of md.split('\n')) {
    if (/^\s*```/.test(line)) {
      flush();
      out.push(line);
      fence = !fence;
    } else if (fence) {
      out.push(line);
    } else if (line.trim() === '') {
      flush();
      out.push('');
    } else if (/^\s*#/.test(line) || /^\s*&#124;/.test(line) || /^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
      flush();
      out.push(line.trimEnd());
    } else if (/^\s*>/.test(line)) {
      const t = line.replace(/^\s*>\s?/, '');
      if (buf !== null && buf.startsWith('>')) buf += ` ${t}`;
      else {
        flush();
        buf = `> ${t}`;
      }
    } else if (/^\s*([-*+]|\d+[.)])\s+/.test(line)) {
      flush();
      buf = line.trimEnd();
    } else if (buf !== null && !buf.startsWith('>')) {
      buf += ` ${line.trim()}`;
    } else {
      flush();
      buf = line.trim();
    }
  }
  flush();
  return out.join('\n');
}

/**
 * The article body, prepared for dev.to, from its Markdown mirror.
 *
 * The mirror is `# title`, provenance lines, a `---`, then the body; only the
 * body is wanted (title, description and canonical are structured fields). Then
 * three fixes for dev.to's markdown: site-relative links are made absolute (dead
 * otherwise); mermaid blocks are dropped, because dev.to mis-parses our
 * ```mermaid <title> fence and spills the rest of the article into a code block,
 * and the diagram is decorative (the canonical page keeps it); and the hard wrap
 * is undone.
 */
function bodyFrom(mirror: string): string {
  const sep = mirror.indexOf('\n---\n');
  const raw = (sep === -1 ? mirror : mirror.slice(sep + 5))
    .replace(/\]\(\//g, '](https://301.sh/')
    .replace(/^```mermaid[\s\S]*?^```[ \t]*$/gm, '')
    .replace(/\n{3,}/g, '\n\n');
  return unwrap(raw).trim();
}

/**
 * Site tags as dev.to tags: lowercase, alphanumeric, at most four. Dev.to
 * rejects a hyphen in a tag, so `bulk-redirects` becomes `bulkredirects` — the
 * same fix the Telegram hashtags needed for the same reason.
 */
function devtoTags(tags: string[]): string[] {
  const clean = tags
    .map((t) => t.toLowerCase().replace(/[^a-z0-9]/g, ''))
    .filter(Boolean);
  // dev.to allows four tags and our articles use at most three, so the spare slot
  // gets a broad, always-relevant tag for reach. Appended last (the article's own
  // tags stay primary), deduped, capped at four.
  if (!clean.includes('webdev')) clean.push('webdev');
  return clean.slice(0, 4);
}

async function publish(env: Env, post: Post, body: string): Promise<void> {
  const response = await fetch('https://dev.to/api/articles', {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      accept: 'application/json',
      'user-agent': '301sh-devto/1.0 (+https://301.sh)',
      'api-key': env.DEVTO_API_KEY,
    },
    body: JSON.stringify({
      article: {
        title: post.title,
        body_markdown: body,
        published: true,
        canonical_url: post.url,
        description: post.description,
        // dev.to crops the cover to ~2.38:1 and eats a 1200×630 OG off the top;
        // the .devto profile is built at that ratio, so nothing is cut.
        main_image: post.image.replace(/\.png$/, '.devto.png'),
        tags: devtoTags(post.tags),
      },
    }),
  });

  // A successful create is 201. Anything else carries a reason in the body worth
  // surfacing — a 422 on tags, say, or a 429 if the cadence is ever raised too far.
  if (response.status !== 201) {
    throw new Error(`dev.to: ${response.status} ${(await response.text()).slice(0, 200)}`);
  }
}

async function run(env: Env): Promise<string> {
  const feed = await fetch(env.FEED_URL, { headers: { 'cache-control': 'no-cache' } });
  if (!feed.ok) throw new Error(`feed: ${feed.status}`);
  const { posts } = (await feed.json()) as { posts: Post[] };

  // Oldest first, one per run: a new dev.to account that publishes a burst reads
  // as spam, so the backfill drips out over days.
  for (const post of posts) {
    const key = `devto:posted:${post.slug}`;
    if (await env.POSTED.get(key)) continue;

    const mirror = await fetch(`${post.url.replace(/\/$/, '')}.md`, {
      headers: { 'cache-control': 'no-cache' },
    });
    if (!mirror.ok) throw new Error(`mirror ${post.slug}: ${mirror.status}`);
    await publish(env, post, bodyFrom(await mirror.text()));

    // Written only after dev.to confirms with 201, so a failure retries on the
    // next run instead of skipping the article silently.
    await env.POSTED.put(key, new Date().toISOString());
    return `posted ${post.slug}`;
  }

  return 'nothing new';
}

export default {
  async scheduled(_event: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
    ctx.waitUntil(
      run(env).then(
        (result) => console.log(`[devto] ${result}`),
        (error) => {
          // Logged rather than swallowed: a cron failure is invisible otherwise,
          // and the next run retries anyway because KV was not written.
          console.error(`[devto] ${error instanceof Error ? error.message : error}`);
          throw error;
        },
      ),
    );
  },
};
Enter fullscreen mode Exit fullscreen mode

The proposal — dev.to, please render Mermaid

Bug 2 exists for one reason: dev.to doesn't render Mermaid. That's why the diagram at the top of this post is a PNG I had to bake — GitHub, GitLab, Obsidian, Notion and VS Code would've drawn it live from this (shown as text; outer fence is four backticks):

```mermaid
flowchart LR
    push[git push] --> build[Astro build] --> feed[/posts.json/]
    feed --> w2[dev.to worker] --> n2((dev.to))
```
Enter fullscreen mode Exit fullscreen mode

Forem is open source. So this isn't a wish — it's a contribution: I've filed a feature request (referencing the 2018 issue #659, closed as "niche," with reopen requests in 2023 and 2025 that went unanswered) proposing native Mermaid rendering, with the KaTeX math support already in Forem as the precedent — filed as forem/forem#23671. Until it lands, the worker above strips Mermaid so nobody else ships a post that eats itself. After it lands, the strip is one line I get to delete.

One last bug, live. The title says four; here is a fifth. Every code block above is indented instead of fenced — because dev.to's Markdown turned my fenced code into raw HTML four separate times while I was writing this, the post about that exact failure. I stopped fighting it and indented the lot. It shipped anyway.


Built as part of 301.sh — a blog about what Cloudflare's free plan actually does, itself running entirely on it. The whole stack, including these workers, is on the about page.

Top comments (0)