DEV Community

Stephano kambeta
Stephano kambeta

Posted on

Cross-Post Your Blog to DEV and Bluesky Automatically

A new blog post goes live on Tuesday. By Thursday it has been shared on one platform and forgotten on the other two.

Writing the article is the hard part. Copying the title, pasting the link and reformatting it for each site is the boring part, and it's the first thing to slip when you're busy.

This guide sets up a simple routine: publish once on your own blog, and let your RSS feed carry the post to DEV and Bluesky. DEV needs no code at all. Bluesky needs one short Python script and a GitHub Actions workflow, and both are free.

The plan

Your blog is the home of every article. Everything else follows from it.

  • DEV: its built-in RSS import creates a draft of each new post. You review it and publish.
  • Bluesky: a script checks your feed every few hours and shares each new article once, with a link card.

Why RSS? Almost every blog platform produces a feed, so this works whether you use WordPress, Ghost, Hugo or something else. It's also a plain, stable format, so there's very little to break.

A link on its own is only part of the story, though. Near the end of Part 2, I'll cover what to do when you want more than a link, like an X thread or a LinkedIn draft for each article.

Do you have a feed?

Your RSS or Atom feed is a web address that lists your latest posts as data. Common places to find it are /rss.xml, /feed and /feed.xml on your site. If none of those work, check your blog platform's settings or documentation.

Open the address in your browser. You should see a page of structured text with your recent post titles and links in it.

Part 1: Import posts into DEV (no code)

DEV can watch your feed and create a draft whenever you publish something new.

  1. In DEV, open Settings, then Extensions
  2. Find the section called "Publishing to DEV Community from RSS"
  3. Paste your feed address
  4. Tick "Mark the RSS source as canonical URL by default"
  5. Click to submit the feed settings, then use "Fetch feed now" to test it

New posts then show up as drafts in your DEV dashboard. Nothing is published until you say so.

The canonical URL option is the one that matters. It tells search engines that your blog holds the original version, so your site gets the credit and the DEV copy isn't treated as a competing duplicate. Leave "Replace links with DEV Community links" unchecked if you want internal links to keep pointing to your own site.

A few things worth knowing about how DEV reads your feed:

  • It uses the full article from the feed's content when there is one. If your feed only has short summaries, your drafts will be short too.
  • The first four categories or tags on each item become the post's tags.
  • The HTML is converted to Markdown, so look over each draft before you publish. Code blocks and images are the usual places to check.

Importing as drafts is a feature, not a limit. It gives you a moment to fix formatting and add a short intro for the DEV audience.

Part 2: Share new posts on Bluesky

DEV has a built-in tool for this job. Bluesky doesn't, so a small script fills the gap.

You could build this half in a no-code tool like Zapier. A scheduled GitHub Actions workflow does the same job for free, and I compared the two in Zapier vs GitHub Actions for small business workflows if you're deciding.

Before you start

  • A Bluesky account
  • A GitHub repository for the script (a new, empty one is fine)
  • An app password for Bluesky. In Bluesky, open Settings, then Privacy and security, then App passwords, and create one. It's a separate password that works for scripts, and you can cancel it at any time without touching your real password. My Bluesky automation bot guide walks through this step with screenshots

The script uses only what Python already includes, so there's nothing to install.

It talks to Bluesky through its API, which is a set of web addresses that apps use to send each other requests. If that idea is new, what an API is and how apps talk to each other explains it in plain language.

Step 1: Add the script

Create a file called post_new_articles.py:

import html
import json
import os
import re
import sys
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime, timezone

FEED_URL = os.environ["FEED_URL"]
HANDLE = os.environ["BSKY_HANDLE"]
APP_PASSWORD = os.environ["BSKY_APP_PASSWORD"]
SERVICE = os.environ.get("BSKY_SERVICE", "https://bsky.social")  # change only if you use another server
MAX_PER_RUN = 3
STATE_FILE = "posted.json"


def fetch(url, data=None, token=None):
    headers = {"User-Agent": "feed-to-bluesky/1.0"}
    if data is not None:
        data = json.dumps(data).encode()
        headers["Content-Type"] = "application/json"
    if token:
        headers["Authorization"] = f"Bearer {token}"
    request = urllib.request.Request(url, data=data, headers=headers)
    with urllib.request.urlopen(request, timeout=20) as response:
        return response.read()


def call(method, data, token=None):
    return json.loads(fetch(f"{SERVICE}/xrpc/{method}", data, token))


def clean(text, limit):
    """Remove HTML tags and extra spaces, and cut the text to a maximum length."""
    text = html.unescape(re.sub(r"<[^>]+>", "", text or ""))
    text = " ".join(text.split())
    return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"


def read_feed(url):
    """Return the posts in an RSS or Atom feed, newest first."""
    root = ET.fromstring(fetch(url))
    posts = []
    for entry in root.iter():
        if entry.tag.split("}")[-1] not in ("item", "entry"):
            continue
        post = {}
        for child in entry:
            name = child.tag.split("}")[-1]
            if name == "link" and "link" not in post:
                post["link"] = (child.get("href") or child.text or "").strip()
            elif name in ("title", "description", "summary") and name not in post:
                post[name] = child.text or ""
        if post.get("link") and post.get("title"):
            posts.append(post)
    return posts


posts = read_feed(FEED_URL)
first_run = not os.path.exists(STATE_FILE)
seen = [] if first_run else json.load(open(STATE_FILE))
new = [p for p in posts if p["link"] not in seen]

if first_run:
    # Don't flood your timeline with old posts the first time
    print(f"First run: remembering {len(new)} existing posts. Nothing is posted.")
    seen += [p["link"] for p in new]
    new = []

to_post, skipped = new[:MAX_PER_RUN], new[MAX_PER_RUN:]
seen += [p["link"] for p in skipped]  # too old to be worth posting now
errors, session = [], None

for post in reversed(to_post):  # oldest first, so the timeline reads in order
    try:
        if session is None:
            session = call("com.atproto.server.createSession",
                           {"identifier": HANDLE, "password": APP_PASSWORD})
        title = clean(post["title"], 250)
        record = {
            "$type": "app.bsky.feed.post",
            "text": f"New article: {title}",
            "createdAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
            "embed": {
                "$type": "app.bsky.embed.external",
                "external": {
                    "uri": post["link"],
                    "title": title,
                    "description": clean(post.get("description") or post.get("summary"), 200),
                },
            },
        }
        call("com.atproto.repo.createRecord",
             {"repo": session["did"], "collection": "app.bsky.feed.post", "record": record},
             session["accessJwt"])
        seen.append(post["link"])
        print("Posted:", title)
    except Exception as error:
        errors.append(f"{post['title']}: {error}")  # not remembered, so the next run tries again

with open(STATE_FILE, "w") as file:
    json.dump(seen[-200:], file, indent=2)

if not new:
    print("No new posts.")
if skipped:
    print(f"Skipped {len(skipped)} older new posts (limit is {MAX_PER_RUN} per run).")
if errors:
    print("Problems:", *errors, sep="\n- ")
    sys.exit(1)
Enter fullscreen mode Exit fullscreen mode

Here's what it does:

  1. It reads your feed and finds the posts it hasn't shared yet.
  2. It logs in to Bluesky with your app password, but only when there's something to post.
  3. For each new article, it posts a short line with a link card showing the title and description.
  4. It saves the list of shared posts in posted.json, so nothing is posted twice.

A few choices keep it from causing trouble:

  • The first run posts nothing. It just remembers what's already in your feed. Otherwise your timeline would fill up with your whole archive.
  • At most 3 posts per run. If you publish a batch, the script won't flood your followers. Older ones in the batch are skipped.
  • Oldest first. If two articles are new, they appear in the order you published them.
  • Failed posts are retried. If Bluesky is down, the article isn't marked as shared, so the next run tries again.

It also reads Atom feeds, not just RSS.

Step 2: Add the workflow

Create .github/workflows/share-articles.yml:

name: Share new articles on Bluesky

on:
  schedule:
    - cron: '11 */3 * * *'   # every 3 hours, at 11 minutes past
  workflow_dispatch:          # adds a "Run workflow" button for testing

concurrency:
  group: share-articles       # never run two at the same time

permissions:
  contents: write             # needed to save posted.json

jobs:
  share:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v4   # use the latest major version

      - name: Post new articles
        env:
          FEED_URL: https://example.com/rss.xml     # change this
          BSKY_HANDLE: yourname.bsky.social          # change this
          BSKY_APP_PASSWORD: ${{ secrets.BSKY_APP_PASSWORD }}
        run: python3 post_new_articles.py

      - name: Save the list of shared posts
        if: always()
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add posted.json
          git diff --staged --quiet || git commit -m "Update shared posts"
          git push
Enter fullscreen mode Exit fullscreen mode

Change FEED_URL and BSKY_HANDLE to your own. Then add your app password as a repository secret: in your repo, open Settings, then Secrets and variables, then Actions, and create a secret named BSKY_APP_PASSWORD. Never paste it into a file.

Some notes on the workflow:

  • Why contents: write? GitHub Actions has no memory between runs, so the workflow saves posted.json back to your repository. It only makes a commit when the list changes.
  • Why every 3 hours? A blog posts a few times a week at most. Eight quick checks a day is plenty, and it costs about 240 minutes a month. That's nothing on a public repository.
  • The odd start time avoids the top of the hour, when GitHub is busiest.

Step 3: Test it

Open the Actions tab in your repository, choose "Share new articles on Bluesky" and click "Run workflow".

The first run should finish with a message like "First run: remembering 12 existing posts. Nothing is posted." That's correct. It now has a baseline.

To see a real post, you have two options:

  • Publish a new article and run the workflow again, or wait for the next scheduled run
  • Open posted.json in your repository and delete the line for your latest article. The next run treats it as new and posts it

If it works, you'll see a post on your Bluesky profile with the article's title and a clickable card.

When a link isn't enough

The script announces each article with its title and a link. That's a fine way to tell Bluesky about it, but every platform has its own style. A thread on X reads differently from a LinkedIn post, and a newsletter teaser is a third thing again.

Writing all of those by hand for every article is the step that usually gets skipped.

Full disclosure: I sell a tool for exactly this, called Repurpose Bot. Here's what it does, and where it may not be the right fit.

It watches your blog's RSS or Atom feed, the same way the script above does. When a new post appears, it asks an AI model to write:

  • An X thread of 5 to 7 posts, with a hook at the start and your link at the end
  • A LinkedIn post of 150 to 250 words
  • A newsletter blurb of 2 to 3 sentences

It then sends those drafts to Telegram, Discord or Slack, whichever you already use. Discord and Slack deliver messages through webhooks. If that term is new, I explain it in what a webhook is.

A few details worth knowing:

  • It sends drafts, not posts. Nothing is published to X or LinkedIn on its own. You read each draft, adjust it and post it yourself, which is a good habit with AI-written text anyway. (Direct posting is something I can build as custom work.)
  • The first run is safe. It skips the posts already in your feed, so you don't get a pile of drafts for your whole archive.
  • Costs stay predictable. A per-run limit caps how many posts it handles at once.
  • It runs on GitHub Actions, the same free setup as the Bluesky script, and you bring your own OpenAI or Gemini key. The AI provider bills you directly for usage.
  • Settings live in one file. Tone, formats, models and delivery channels are all in config.yaml, and adding another format, like an Instagram caption, takes three lines.
  • Price: a one-time $29, with no subscription. Setup takes about 10 minutes in the browser.

If all you want is your article's link on Bluesky, the free script in this guide is enough. Repurpose Bot is for the case where you want the thread, the LinkedIn post and the newsletter teaser drafted for you, and you're happy to review them.

You can also build it yourself. The feed reading and the "never post twice" logic are already in the script above, and you'd add a call to an AI provider. The parts that take the longest are the unglamorous ones: retrying failed requests, limiting how many posts go out at once, and splitting long messages to fit each platform.

If you write on DEV first

Some people publish on DEV first and want Bluesky to follow. That works too. DEV gives every account a feed, and you can point the script at it:

https://dev.to/feed/your-username
Enter fullscreen mode Exit fullscreen mode

Use that address as FEED_URL, and each new DEV post gets shared on Bluesky.

What can go wrong

Your feed has summaries only. DEV drafts will be short, and you'll need to paste in the rest. Check whether your blog platform can include the full article in the feed.

The link card has no image. The script keeps things simple and shares the title and description only. Adding a picture means uploading it to Bluesky first, which is doable but adds code.

Your app password stops working. If you cancel it or change your handle, the run fails and GitHub emails you. That's the right outcome, since the alternative is silence.

The state file is lost. If posted.json is deleted, the script treats your next run as a first run and posts nothing, then starts fresh. It won't re-share your archive.

The workflow gets switched off. In a public repository, GitHub disables scheduled workflows after 60 days with no repository activity. If new posts stop appearing on Bluesky, check the Actions tab. I go into this more in how to monitor your automations when something breaks.

A note on good behavior

Automation makes posting easy, but it's still your account and your followers.

One post per article is plenty. Don't add extra automated posts that repeat the same link, and don't let a bug post the same thing ten times. The "never post twice" and "three at most" rules in the script exist for that reason.

It's also worth asking whether a task should be automated at all. Announcing a new article is a good fit, because it's repetitive, predictable and low risk. For tasks that need judgment, a person should stay involved. I wrote about how to tell the difference in When Not to Automate: 6 Signs a Task Isn't Worth It.

Quick answers

Will DEV publish my posts automatically?
No. Imported posts arrive as drafts, and you publish them yourself.

Will cross-posting hurt my search ranking?
Setting the canonical URL tells search engines which copy is the original. With the box ticked, DEV marks your blog as the source.

Can I import my old posts?
DEV's feed import will pick up what's in your feed. For Bluesky, the script deliberately skips old posts on the first run.

What if I publish several posts in one day?
The script shares up to 3 per run, oldest first. Anything beyond that in the same run is skipped.

Can I get an X thread or a LinkedIn post written for each article?
That's a different job from sharing a link. Repurpose Bot does it: it writes an X thread, a LinkedIn post and a newsletter blurb for each new post and sends the drafts to Telegram, Discord or Slack for you to review. It's a paid product. The Bluesky script in this guide stays free.

Can I use this for another platform?
If the platform has an API for posting, the same idea works: read the feed, remember what's been shared, post the rest. Only the posting step changes.

Does it cost anything?
No. A run every 3 hours uses about 240 minutes a month, which is free on a public repository and a small slice of the allowance on a private one.

The short version

Publishing in more than one place shouldn't mean doing the same work three times. Let your blog be the original, let DEV import it as a draft, and let a small script announce it on Bluesky.

Start with the DEV import, since it takes five minutes and needs no code. Add the Bluesky script when you're ready.

You can find more practical automation guides on Procwire.

Top comments (0)