# Automatically cross-posting static blog posts to Bluesky with GitLab CI/CD
Table of Contents
I thought it would be “fun” to try to get this blog to automatically cross-post itself to my Bluesky account whenever I publish a new post. I based this project on this blog post by Raymond Camden, “Automatically Posting to Bluesky on New RSS Items”. Raymond used Pipedream to listen for changes to his blog’s RSS feed, but I figured that since I’m already using GitLab’s CI/CD, and that each RSS feed update would correspond to a merge to master, I could just automate the RSS feed check myself. That proved to be a bit annoying, so hopefully documenting this process can help others avoid some of the guessing-and-checking I had to do. Anyway, the basic process here is:
- Wait for the Astro build to complete.
- Somehow get an old version of
rss.xmlto compare with the newly builtrss.xmland see if any new posts have been added on this merge. - Call the Bluesky API to make a post with the blog post title/link/etc.
Getting the old RSS feed
So, first thing’s first, I have a job defined in my .gitlab-ci.yml that looks like this:
get_prev_rss: image: alpine/curl stage: build rules: # only run on default branch OR debug - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - if: $CI_COMMIT_BRANCH == "debug_rss" script: - export OLD_RSS_URL="https://${CI_SERVER_HOST}/api/v4/projects/${CI_PROJECT_ID}/jobs/artifacts/master/raw/public/rss.xml?job=build-and-deploy" - echo "Pulling old RSS from ${OLD_RSS_URL}" - 'curl --location --header "PRIVATE-TOKEN: ${GITLAB_ARTIFACT_READ_TOKEN}" --output "prev_rss.xml" --url $OLD_RSS_URL' # debug what got downloaded - head -n1 prev_rss.xml artifacts: paths: - prev_rss.xmlYou can see the rest of the GitLab CI/CD config file in my previous post about this blog’s tech stack, here.
What this does is call the GitLab API to fetch the artifact public/rss.xml from the last successful build-and-deploy job that ran on the master branch for my project (identified by CI_PROJECT_ID). It saves that file as prev_rss.xml and exposes it as an artifact so that the next job in the pipeline, which handles the actual build + deploy steps, can access it (at the end of that job, this pipeline’s public/rss.xml is exposed as an artifact for the next pipeline!). According to the GitLab docs, job artifacts are persisted indifinitely for the last successful run of a job, so my assumption is that this will keep working even if I have huge gaps between posts (the default lifetime is 30 days otherwise, fyi).
Figuring out how to authenticate the API call here was a pain in the ass. GitLab’s CI_JOB_TOKEN is not authorized to call the “branch name + individual file path” endpoint, so I had to define a new variable, GITLAB_ARTIFACT_READ_TOKEN, that stores a project access token that has the read_api scope. I found it was important NOT to check “Protect variable” when creating that variable, the job couldn’t access it otherwise. Another wrinkle: curl needs the --location flag to navigate GitLab’s auth redirect flow.
I defined get_pref_rss as its own job instead of as part of the main deploy + build job so that I could debug it more easily, thus the if: $CI_COMMIT_BRANCH == "debug_rss" rule. Waiting for the entire build + deploy to finish before finding out if the old RSS feed downloaded correctly was a massive pain in the ass.
Posting to Bluesky
prev_rss.xml is passed to the next job in the pipeline, and after the build and deploy to Neocities completes, that job calls npm run bsky which itself calls tsx post_to_bsky.ts, which handles the Bluesky API call:
import { readFile } from 'fs/promises'import RssParser from 'rss-parser'import path from 'node:path'
// https://www.npmjs.com/package/@atproto/apiimport { Agent, CredentialSession, RichText } from '@atproto/api'
const OLD_XML_PATH = 'public/rss.xml'const NEW_XML_PATH = 'prev_rss.xml'
const BSKY_HANDLE = process.env.BSKY_HANDLEconst BSKY_API_KEY = process.env.BSKY_API_KEY
if (!BSKY_API_KEY) { throw Error('Missing BSKY_API_KEY!')}
if (!BSKY_HANDLE) { throw Error('Missing BSKY_HANDLE!')}
const rssParser = new RssParser({ defaultRSS: 2 })
const readRssFeed = async (filePath: string): Promise<RssParser.Output<unknown>> => { try { const oldFileXml = await readFile(path.join(process.cwd(), filePath)) return await rssParser.parseString(oldFileXml.toString()) } catch (e) { throw new Error(`Could not load RSS feed at ${filePath}: ${e}`) }}
const getNewestPost = async (): Promise<RssParser.Item | null | undefined> => { try { const oldRssFeed = await readRssFeed(OLD_XML_PATH) const newRssFeed = await readRssFeed(NEW_XML_PATH)
const oldPostIds = oldRssFeed.items.map((item) => item.guid) console.log(`${oldPostIds.length} old posts found`)
return newRssFeed.items.filter((item) => !oldPostIds.includes(item.guid)).pop() } catch (e) { console.error(`Error computing newest post: ${e}`) return null }}
const getBskyAgent = async () => { const session = new CredentialSession(new URL('https://bsky.social')) await session.login({ identifier: BSKY_HANDLE, password: BSKY_API_KEY, }) return new Agent(session)}
const postToBsky = async (agent: Agent, post: RssParser.Item) => { const rt = new RichText({ text: `New blog post! "${post.title}" ${post.link}` })
await rt.detectFacets(agent)
console.log(`Posting ${rt.toString()}`)
await agent.post({ $type: 'app.bsky.feed.post', text: rt.text, facets: rt.facets, createdAt: new Date().toISOString(), })}
const main = async () => { const newestPost = await getNewestPost() const agent = await getBskyAgent()
if (newestPost) { await postToBsky(agent, newestPost) } else { console.log('No new posts') }}
main()This script is longer but I think it’s more self-evident what’s going on here. The script runs main, which first calls getNewestPost. This parses out both the old and new RSS files and filters the new list of posts down to the ones with guids not present in the old list of posts. The RSS feed should list posts in publish-order, so popping the list of new posts here should give me the most recent new post.
Then main creates a new Bluesky agent in getBskyAgent. This is just by-the-book stuff for the @atproto/api library. Note that the environment variables BSKY_HANDLE and BSKY_API_KEY are required, that’s your Bluesky username and an API key you can create in Bluesky settings under Privacy and Security > App passwords. Ive structured things in this order so that if the post identification or login steps fail, they don’t block each other, and you can see both errors in the GitLab logs for debugging.
Then, if a newestPost exists, the script uses the ATproto library’s RichText utility to structure the Bluesky post content and then call the agent’s post method to post the post.
That’s about it! I’m writing this post partially as a final test that the script works, so fingers crossed that it functions as expected, and that it isn’t too annoying to clean up if it doesn’t.
US out of everywhere
I’m writing this article the day after the beginning of joint US-Israel attacks on Iran, finally satisfying our government full of Holden Bloodfeasts that have been waiting for an excuse to kill Iranians for decades.

Regime change wars have never and will never liberate the people whose leaders they kill, no matter how despotic, they only sow political chaos, murder civillians, destroy the infrastructure that makes comfortable life possible for those left alive, and enrich the American ruling class. I’ll leave you with two quotes from two comics legends, Iranian author Marjane Satrapi and Garfield, the cat, that capture how I feel.
If I have one message to give to the secular American people, it’s that the world is not divided into countries. The world is not divided between East and West. You are American, I am Iranian, we don’t know each other, but we talk together and we understand each other perfectly. The difference between you and your government is much bigger than the difference between you and me. And the difference between me and my government is much bigger than the difference between me and you. And our governments are very much the same.
- Marjane Satrapi
people who [do a thing that just happened] should be drug out into the street and shot
- Garfield