posts/This post was published from a git repository and here's how I did it.md
···11+(This write-up is intended for a technical audience and as such will be sparse on explanations and definitions of things)
22+33+44+Last week I found out about [WhiteWind](https://whtwnd.com/) and [tangled](https://tangled.sh/) 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:
55+66+77+# What does a WhiteWind blog post look like under the hood?
88+99+1010+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](https://pdsls.dev/).
1111+1212+1313+```json
1414+{
1515+ "$type": "com.whtwnd.blog.entry",
1616+ "theme": "github-light",
1717+ "title": "Test Post Please Ignore",
1818+ "content": ...,
1919+ "createdAt": "2025-08-14T13:05:58.823Z",
2020+ "visibility": "author"
2121+}
2222+```
2323+2424+2525+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?
2626+2727+2828+# Enter goat
2929+3030+3131+[goat](https://github.com/bluesky-social/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.
3232+3333+3434+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](https://bsky.app/profile/m1emi1em.dev/post/3lwenaigagl2b) when I did a [test push to main on a repo](https://tangled.sh/@m1emi1em.dev/test-repo-please-ignore/pipelines/760/workflow/build.yml).
3535+3636+3737+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.
3838+3939+4040+# Defining _exactly_ what I want to do
4141+4242+4343+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:
4444+- Either delete every single record under `com.whtwnd.blog.entry` and recreate them from the `.md` files in my blog repository
4545+- 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
4646+4747+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.
4848+4949+Which meant I needed some way to correlate which WhiteWind post records came from which files in the git repository.
5050+5151+5252+# rkeys
5353+5454+5555+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 most (all?) of their records.
5656+5757+5858+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.
5959+6060+6161+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](https://github.com/whtwnd/whitewind-blog/blob/main/lexicons/com/whtwnd/blog/entry.json#L8). 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.
6262+6363+6464+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](https://github.com/BlushSocial/atproto-tid) which wasn't too difficult to adapt into what I ended up writing to glue all of this together.
6565+6666+# Clojure?
6767+6868+6969+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.
7070+7171+7272+[Babashka](https://babashka.org/) also exists, which is a Clojure runtime intended for scripting and is built on the GraalVM for fast startup times.
7373+7474+7575+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.
7676+7777+7878+# But does it work?
7979+8080+8181+Mostly, I think? I haven't tested it significantly 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](https://tangled.sh/@m1emi1em.dev/test-repo-please-ignore/pipelines) and, hopefully, should also be reading this post just fine on WhiteWind as well.
8282+8383+8484+The script for all this is [publish.bb](https://tangled.sh/@m1emi1em.dev/test-repo-please-ignore/blob/main/publish.bb) in the root of the repo this was published from (if you really want to read the messiest Clojure possible).
8585+8686+8787+# Aftermath
8888+8989+9090+This was a fun idea to explore and gave me an opportunity to both 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.
9191+9292+9393+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:
9494+- Sharing a new post on BSky on post creation. This is super doable.
9595+- 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 to hard on how to best do this.
9696+- 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.
9797+9898+9999+Somewhere along the way I was also messing around with making raw HTTP/XRPC requests to my PDS which were rather tedious to wrap 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 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.
100100+101101+102102+---
103103+104104+105105+Thanks to all the people who helped me throughout the course of doing all this:
106106+- @nelind.dk 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
107107+- @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
108108+- @zed.earth for pointing me to their Clojure lib for TID handling [BlushSocial/atproto-tid](https://github.com/BlushSocial/atproto-tid)