(This write-up is intended for a technical audience and as such will be sparse on explanations and definitions of things)

Last week I found out about WhiteWind and tangled which led me to wonder if it would be possible to publish blog posts to WhiteWind from a git repository via CI. I am glad to report that it is indeed possible, and here's the broad overview of how I did it:

What does a WhiteWind blog post look like under the hood?#

The first thing to figure out was what a blog post on WhiteWind looks like on the PDS, so I made one, saved it, and looked at it through PDSls.

{
	"$type": "com.whtwnd.blog.entry",
	"theme": "github-light",
	"title": "Test Post Please Ignore",
	"content": ...,
	"createdAt": "2025-08-14T13:05:58.823Z",
	"visibility": "author"
}

All of this is pretty self-explanatory. Creating a record like this from a Markdown file would be trivial. But how can we do that?

Enter goat#

goat is a CLI tool for interacting with AT Protocol. Using goat, we can do everything we will need for this project: listing records for an account, querying records from a specific collection, as well as querying, updating, or deleting specific records.

Running it as part of CI is also really straightforward, I was pleasantly surprised to find out. It was a lot easier than I expected to get goat to authenticate with my PDS and make a post on bsky when I did a test push to main on a repo.

Making a record for a blog post on WhiteWind with goat is slightly more involved than a bsky post. But it's just a matter of making a JSON file and feeding that to goat so it's still very easy.

Defining exactly what I want to do#

It was at this point in the process I was faced with the realization that I had to do one of two things on every sync of blog posts to my PDS for the main idea I had to work:

  • Either delete every single record under com.whtwnd.blog.entry and recreate them from the .md files in my blog repository
  • Or figure out a way to keep track of which records on my PDS - which blog posts - were published automatically and should be managed when syncing the repo to the PDS

I chose to go with the second path because I'd rather not have to potentially manage everything I might write on WhiteWind through a git repository. If I create a post through the web interface for WhiteWind, I didn't want it to be automatically deleted or modified by whatever publishing script I ended up using for publishing stuff through git.

Which meant I needed some way to correlate which WhiteWind post records came from which files in the git repository.

rkeys#

Record keys, or rkeys, are used to reference a specific record in a collection in a repository on a PDS. They are most commonly TIDs generated at the time the record was created - this is what bsky uses for (almost) all of their records.

My idea for tracking which records correspond to which files in the git repo was: write my own records in my own, separate collection on my PDS that contained the local file path to a source .md file using the same rkey as the WhiteWind blog record created from that file. An example of one of these records be viewed through PDSls here.

WhiteWind, at least at the time of writing, doesn't do any validation when getting stuff from a user's PDS that a record has a valid TID as an rkey like their lexicon would imply. So it's totally possible to just create a post with anything that's a valid rkey, but that seems sketchy to rely on and would break if WhiteWind ever actually validated that. Which meant that, if I didn't want to rely on behavior that shouldn't even work in the first place, I had to figure out TIDs.

Fortunately TIDs are relatively straightforward, and even more thankfully when I was posting occasionally on bsky throughout the process of writing a script to do all of this, @zed.earth pointed me in the direction of his own TID library for Clojure which wasn't too difficult to adapt into what I ended up writing to glue all of this together.

Clojure?#

Yeah so to keep it brief: I really like Clojure. I really like Clojure. It's a lisp dialect that runs on the JVM. It's a dynamically-typed, primarily functional programming language. But it's also by far the language I've used the most and am most comfortable with at this point in time.

Babashka also exists, which is a Clojure runtime intended for scripting and is built on the GraalVM for fast startup times.

So naturally I ended up writing the script which wraps goat, hashes the repo's .md files and PDS records to compare them, and creates, updates, and deletes records accordingly in Clojure using Babashka.

But does it work?#

Mostly, I think? I haven't tested it extensively but it seems to work and that's good enough for a simple proof of concept. You can view the CI logs on tangled here and, hopefully, should also be reading this post just fine on WhiteWind as well.

The script for all this is publish.bb in the root of the repo this was published from (if you really want to read the messiest Clojure possible).

Aftermath#

This was a fun idea to explore and gave me an opportunity to dip my toes into doing stuff with atproto. It was also my first foray into anything CI which ended up being much simpler than I was expecting it to be.

I'd also like to refactor and rewrite my script at some point to make it easy for others to use to also publish to WhiteWind from a git repo if they wanted to. There's also a few other things I'm interested in figuring out and adding to it as well:

  • Sharing a new post on bsky on post creation. This is super doable.
  • Figuring out a better way to handle post titles and post visibility. Right now the script just makes a post title by trimming the .md from the end of the filename and is also hardcoded to create the record with a public visibility. Something like Obsidian-style YAML frontmatter to specify a post title and visibility seems like a good solution? But I haven't thought too hard on how to best do this.
  • WhiteWind supports embedding images into posts via blobs on a post's record. What I have right now does not account for this or do anything to allow it. It would be nice to have this.

Somewhere along the way I was also messing around with making raw HTTP/XRPC requests to my PDS which were rather tedious to work with so I settled for just wrapping goat for this project. But briefly messing around with that has had me considering exploring automating the generation of functions to wrap XRPC stuff from lexicons in Clojure. Which sounds like a very ambitious project, especially when I currently don't know very much about atproto overall. But it does sound like it'd be fun to figure out.

I should probably also figure out how to do anything lexicon-related besides just telling the PDS to not validate against one when modifying or creating records too.


Thanks to all the people who helped me throughout the course of doing all this:

  • @nel.pet helped me a ton with figuring out CI basics, any atproto questions I had, and also helped me sort out running into a bug with tangled's secret handling
  • @oppi.li and @icyphox.sh for tangled, as well as also getting a fix for secret handling on tangled's end figured out and made live as quickly as they did
  • @zed.earth for pointing me to their Clojure lib for TID handling BlushSocial/atproto-tid