DEV Community

Cover image for How I schedule three daily Bluesky posts from a JSONL queue without an external service
MORINAGA
MORINAGA

Posted on Edited on

How I schedule three daily Bluesky posts from a JSONL queue without an external service

The Bluesky image upload race I fixed a few weeks ago was the last painful incident in an otherwise simple posting pipeline. Here's how the queue system works — the design is different from every social-scheduling SaaS I looked at, and that difference matters on GitHub Actions.

The queue: a flat JSONL file

The entire post schedule lives in content/bluesky-queue.jsonl. Each line is a self-contained JSON object:

{"id": "073", "text": "Long context vs. RAG isn't a versus. […] #ai", "generated_at": "2026-05-22T22:12:18Z", "trending_anchor": "evergreen — long context vs. RAG architecture"}
{"id": "074", "text": "Cloudflare Workers runs at 300+ PoPs with no cold starts. […] #webdev", "generated_at": "2026-05-22T22:12:18Z", "trending_anchor": "evergreen — edge runtime comparison for programmatic sites"}
{"id": "013", "text": "AdSense rejection emails just say 'site not yet ready' […] #webdev", "posted_at": "2026-05-07T03:07:59.010Z", "post_uri": "at://did:plc:a4i264.../app.bsky.feed.post/3mlaafamauz2c", "post_url": "https://bsky.app/profile/morinaga2222.bsky.social/post/3mlaafamauz2c"}
Enter fullscreen mode Exit fullscreen mode

An unposted entry carries id, text, generated_at, and a trending_anchor note recording why I wrote that line. After a post succeeds, the script rewrites the line in-place with posted_at, post_uri, and post_url added — so "posted" is just the presence of those keys, not a separate state column. The queue drains from top to bottom; the script picks the first line without a posted_at field and exits after posting one entry. (That's the May 2026 behaviour described here — in late June I changed the picker to score unposted entries by hashtag engagement first, with file order as the tiebreak.)

This format is a deliberate trade-off. It's not a real database. You can't query it, you can't easily filter by tag, and editing it by hand means being careful about JSON syntax on every line. What it gives you: a single file that's diff-friendly in git history, trivially readable, and appended to by any script or CI job that generates content. One thing it deliberately isn't: the article distribution path. publish-articles.yml posts article announcements to Bluesky directly through packages/publish, without ever touching this file — the queue is only for the standalone commentary posts.

The post script: richtext facets for hashtags and URLs

Bluesky's API expects richtext facets — byte-range annotations that tell the client which parts of the text are links or hashtags. These aren't inferred; you have to compute them and include them in the post record. The post script builds them from the text string using regex:

function buildFacets(text) {
  const facets = [];
  const enc = new TextEncoder();

  for (const m of text.matchAll(/(?:^|[\s,.;:!?])(#[a-zA-Z][a-zA-Z0-9_]*)/g)) {
    const tagWithHash = m[1];
    const offset = (m.index ?? 0) + m[0].length - tagWithHash.length;
    const byteStart = enc.encode(text.slice(0, offset)).length;
    const byteEnd = byteStart + enc.encode(tagWithHash).length;
    facets.push({
      index: { byteStart, byteEnd },
      features: [{ $type: "app.bsky.richtext.facet#tag", tag: tagWithHash.slice(1) }],
    });
  }

  for (const m of text.matchAll(/https?:\/\/[^\s)]+/g)) {
    const byteStart = enc.encode(text.slice(0, m.index ?? 0)).length;
    const byteEnd = byteStart + enc.encode(m[0]).length;
    facets.push({
      index: { byteStart, byteEnd },
      features: [{ $type: "app.bsky.richtext.facet#link", uri: m[0] }],
    });
  }

  return facets;
}
Enter fullscreen mode Exit fullscreen mode

The byte offset calculation is the non-obvious part. Bluesky byte ranges are UTF-8 byte positions, not JavaScript character positions. A string with emoji before a hashtag would have different byte and character offsets. Using TextEncoder to measure text.slice(0, offset) gives the correct UTF-8 byte position regardless of what precedes the match.

Off-minute cron scheduling

The workflow fires three times daily (as of May 2026 — I cut it to the single 17:00 JST slot on 2026-05-28 to save Actions minutes; most runs were finding an empty queue):

schedule:
  # cron is UTC; the comment is the JST time it lands on
  - cron: "37 23 * * *"   # 23:37 UTC → ~09:00 JST
  - cron: "37 7 * * *"    # 07:37 UTC → ~17:00 JST
  - cron: "37 13 * * *"   # 13:37 UTC → ~23:00 JST
Enter fullscreen mode Exit fullscreen mode

The :37 offset is intentional. GitHub Actions schedules at top-of-hour slots — 0 * * * *, 0 0 * * * — are heavily contended globally. I measured 3–4 hour actual delays on a 0 0 * * * slot before moving to :37. The off-minute timing doesn't eliminate delay but reduces it significantly; real-world delivery now lands within 15–20 minutes of the intended JST time.

Inside the job, there's a random additional delay before posting:

- name: Random start delay (0-5 min) to avoid bot-pattern timing
  run: |
    DELAY=$(( RANDOM % 300 ))
    echo "Sleeping ${DELAY}s before posting"
    sleep $DELAY
Enter fullscreen mode Exit fullscreen mode

This spreads the actual post time across a 5-minute window. Bluesky's feed algorithms tend to de-emphasize accounts that post at machine-exact times; the random delay makes the pattern look more organic. I don't have data to prove it works, but the cost is zero.

Self-trigger prevention (that isn't actually load-bearing here)

After posting, the script rewrites content/bluesky-queue.jsonl and commits the change back to the repo. In a workflow with a push trigger that commit would start the workflow again and drain the queue faster than intended, so I carried over the commit-message guard I use elsewhere:

- name: Commit queue update
  run: |
    git add content/bluesky-queue.jsonl
    git commit -m "chore(bluesky): mark queued post as posted [skip bluesky-queue]"
    git push
Enter fullscreen mode Exit fullscreen mode
jobs:
  post:
    if: "!contains(github.event.head_commit.message, '[skip bluesky-queue]')"
Enter fullscreen mode Exit fullscreen mode

The workflow skips if the triggering commit contains [skip bluesky-queue]. Being honest about it: in this workflow the guard does nothing, because bluesky-queue.yml only has schedule and workflow_dispatch triggers — there is no push trigger for the commit to re-fire, and github.event.head_commit is empty on a scheduled run anyway. It's insurance against the day I add one.

Where the pattern actually earns its keep is publish-articles.yml, which is push-triggered on content/articles/**: its own OG-image and published-URL commits end in [skip publish-articles], or every publish would echo forever. The two YouTube publishers use their own tokens ([skip yt-publish], [skip yt-publish-samurai-princess]) so they don't skip each other. It isn't applied uniformly across the shared CI pipelinerefresh-content.yml commits ETL data with no token at all, which is safe only because it, too, has no push trigger.

What I'd do differently

The JSONL format breaks down if you want to schedule posts for a specific future date rather than "next in queue." A scheduled_after ISO timestamp field would fix this without changing the format much; the picker logic shifts from "first line without posted_at" to "first line where scheduled_after <= now and posted_at is absent."

The other gap: no retry backoff on failed posts. If the Bluesky API returns an error, the script exits and the next scheduled run retries the same entry — correct behavior, but without backoff a transient 500 hits the same entry three times across the day's posting slots. So far that hasn't caused duplicate posts, but it's a latent issue worth fixing before the account grows.

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 (1)

Collapse
 
frank_signorini profile image
Frank

Really interesting approach! I've been meaning