DEV Community

Cover image for Three things an expired API token broke in my cross-publish pipeline
MORINAGA
MORINAGA

Posted on

Three things an expired API token broke in my cross-publish pipeline

Last week my cross-publish pipeline started failing daily. The GitHub Actions job was red, the failure count in the watchdog issue was climbing, and articles were duplicating on Dev.to. One Hashnode API token had expired quietly, and three unrelated behaviors were coupled to it in ways I hadn't designed intentionally.

Here's the cascade, in dependency order.

Bug 1: A systemic error failed the whole job instead of deferring

The publish step in packages/publish/src/index.ts iterates articles and platforms, catches errors, and continues. The error branching before the fix:

if (e instanceof RateLimitError) {
  rateLimited.add(platform);
  totalDeferred++;
  console.log(`   ⏳ ${platform}: rate limited — deferred`);
} else {
  totalFailures++;
  if (e instanceof SystemicError) {
    brokenThisRun.add(platform);
    console.error(`   ⏹  ${platform}: systemic fault — circuit-broken`);
  }
}
Enter fullscreen mode Exit fullscreen mode

RateLimitError deferred the platform gracefully. SystemicError — an expired token, a 5xx, an HTML error page — also incremented totalFailures, which caused process.exit(1) at the end of the run.

That seemed defensible: a system-level error is serious. The problem is that a SystemicError fails identically for every article in that platform. It's not this article's fault; it's not this run's fault. Treating it as a job failure meant the entire job went red every run until I rotated the Hashnode token — with no indication that one specific platform was the issue.

The fix: treat SystemicError identically to RateLimitError — circuit-break the platform, defer, emit a GitHub Actions ::warning:: annotation, continue. The warning surfaces the expired-token fact loudly without failing the job:

} else if (e instanceof SystemicError) {
  brokenThisRun.add(platform);
  totalDeferred++;
  console.error(`   ⏹  ${platform}: systemic fault — circuit-broken + deferred`);
  console.log(
    `::warning title=${platform} publishing broken::${platform} returned a systemic error (likely an expired/invalid API token). Rotate its token. Affected articles were DEFERRED and will publish on the next run.`,
  );
}
Enter fullscreen mode Exit fullscreen mode

One platform's authentication problem no longer stops Dev.to and Bluesky from publishing.

Bug 2: The URL commit-back step was gated on publish success

After publishing, the workflow commits Dev.to and Hashnode URLs back into each article's frontmatter. Those stored URLs drive the Bluesky cross-post, which needs an existing published URL to link to.

The step condition in .github/workflows/publish-articles.yml:

- name: Commit published URLs back
  if: success()
  run: git add content/articles/ && git commit -m "..."
Enter fullscreen mode Exit fullscreen mode

When the publish step exited non-zero (because Hashnode's SystemicError was still being treated as a job failure at the time), success() was false. The commit-back step was skipped. Articles that did publish to Dev.to — successfully, with real URLs — never had those URLs persisted.

Next run: the pipeline saw those articles as unpublished (no published_urls in frontmatter) and re-published them. New Dev.to slug, duplicate Bluesky post. This is how one article ended up re-published eleven times across two days. Each time, a new slug meant a new URL, so the deduplication check never triggered.

The fix is one word:

- name: Commit published URLs back
  if: always()
  run: git add content/articles/ && git commit -m "..."
Enter fullscreen mode Exit fullscreen mode

Persist what published, unconditionally. URL persistence isn't a reward for a successful run — it's a record-keeping step that should run regardless of what else happened.

Bug 3: The Bluesky timeout was shorter than the random start delay

The Bluesky queue workflow uses a random 0–5 minute start delay to spread fire time across a window and avoid predictable bursts at the AT Protocol rate limiter. The job's timeout-minutes was set to 5.

When the random delay rolled high — say, 4 minutes 45 seconds — the job would start, wait, and then get cancelled before it posted. The post remained in the queue, not marked as sent. Over several days the queue backed up from 7 to 12 unposted items.

This one is obvious in retrospect. The timeout must exceed the max possible delay plus the actual work. The actual post takes under 30 seconds; the max delay is 5 minutes. So:

timeout-minutes: 12  # was 5; must cover max 5-min delay + ~30s work
Enter fullscreen mode Exit fullscreen mode

What made it hard to notice: a job cancelled at the timeout limit shows as "completed" in the Actions dashboard with a non-zero exit code, but it looks visually similar to a succeeded job. The Bluesky queue has no circuit-breaker that alerts when unposted count exceeds a threshold. I caught it by manually checking the queue files.

What the failure pattern looked like

The three bugs were causally independent but temporally concurrent, which made the initial diagnosis confusing. The watchdog issue showed "Publish articles failed" eleven times in a row — that was the SystemicError → job failure bug. The Bluesky stall was invisible in the dashboard. The duplicate Dev.to posts were visible but I initially attributed them to a different cause.

Once I traced it back to the expired Hashnode token as the initial trigger, the cascade made sense:

  1. Expired token → SystemicError → job failure (instead of warn + defer)
  2. Job failure → success() false → commit-back skipped → URLs not persisted → re-publish loop
  3. Unrelated timing issue: Bluesky timeout shorter than its own random start delay

The lesson about success-gated steps

The success() condition on the commit-back step is the systemic issue. When step B only runs if step A succeeds, you've coupled B's fate to every failure mode in A — including ones that have nothing to do with B. In this case, "Hashnode's token is expired" caused "Dev.to URLs are lost", which are completely unrelated concerns.

Audit your if: conditions. Steps that record or persist state should usually run always(). Steps that publish or act externally might reasonably gate on success. The distinction matters more than it looks.


Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.

Top comments (0)