Table Of Contents
I wanted a small thing: when I publish a new post here, a short status shows up on adjb.status.lol with a link back to the real post. Not a syndication system, not another social bridge to babysit. My EchoFeed is too busy for all that. I just a note that says "new post" on adjb.status.lol and points home because there is no place like 127.0.01.
The OMG API made me say OMG with no LOL, it was way too much information Here's what I ended up with: a GitHub Actions workflow that finds new posts in content/blog, posts them to https://adjb.status.lol/, and remembers what it already posted so it doesn't spam the same six posts every night.
Robb Knight's Alfred workflow, StatusPost, is what actually got me moving. His script is small and plain, and it's exactly the kind of thing that makes an API make sense to me:
curl --location --request POST \
--header "Authorization: Bearer $API_KEY" \
"https://api.omg.lol/address/$USERNAME/statuses/" \
--data '{"emoji": "📝", "content": "Hello status.lol"}'The endpoint is POST https://api.omg.lol/address/{address}/statuses/, the token is a bearer token in the header, and the body just needs emoji and content. Once I had that, the question became how to I automate this via github workflows so I can make this safe to run automatically, every day, without me thinking about it.
The setup #
(again thanks to Robb Knight)
This site runs on Eleventy BuildAwesome. Posts are Markdown files in content/blog with front matter like:
---
title: Making Static Sites FAIR with metadata.json, Zotero, Eleventy, and Jekyll
date: 2026-07-05T00:00:00.000Z
tags:
- eleventy
- metadata
- zotero
---Eleventy turns those into pages like https://www.adamdjbrett.com/blog/fair-signposting-metadata-json-eleventy/. status.lol is part of the omg.lol ecosystem — my address is adjb, so my status page lives at https://adjb.status.lol/.
I already had a workflow refreshing feeds and webmentions on a schedule. I just needed to bolt on a publishing step without turning it into a machine that reposts my whole archive every night (thanks revert).
The workflow needs a memory #
This is the part that actually took thought. If the script scans content/blog and posts everything it finds, it reposts the archive. If it only ever looks at the last seven days, it'll miss backdated posts or posts I edit later. If it just checks the latest git commit, it gets fragile the moment I move or restore a file.
So instead there's a small state file, _data/statuslog-published.json, tracking two things:
publishedUrls— URLs actually posted to status.lolknownUrls— URLs that existed the last time the workflow ran
The first run backfills the last seven days. After that, it stops thinking in terms of "recent" and just asks whether a URL is new compared to what it already knew about. The seven-day window is fine for a first run, but it's a bad permanent definition of "new" — the state file is what gives the whole thing memory across runs.
Building the request #
Robb's script showed me the shape. The payload is just:
{
"emoji": "📝",
"content": "New blog post: [Title](https://example.com/blog/post/)"
}I build that as a plain JS object and let JSON.stringify handle the escaping:
const payload = {
emoji: DEFAULT_EMOJI,
content: `New blog post: [${markdownEscape(post.title)}](${post.url})`
};That's safer than building a raw curl string by hand — post titles have quotes, apostrophes, and parentheses, all the characters that make shell escaping miserable. Let JS build the data as data and serialize it correctly.
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${OMGLOL_KEY}`
},
body: JSON.stringify(payload)
});The URL comes from the omg.lol address: https://api.omg.lol/address/${OMGLOL_USERNAME}/statuses/. In GitHub Actions, OMGLOL_USERNAME is a secret — for this site, it's adjb.
The script #
The publishing script lives at scripts/publish-statuslog-posts.mjs. Up top it reads:
const OMGLOL_USERNAME = process.env.OMGLOL_USERNAME;
const OMGLOL_KEY = process.env.OMGLOL_KEY;
const STATUSLOG_DRY_RUN = process.env.STATUSLOG_DRY_RUN === "true";
const STATUSLOG_BACKFILL_DAYS = Number.parseInt(
process.env.STATUSLOG_BACKFILL_DAYS || "7",
10
);The GitHub secrets are OMGLOL_USERNAME and OMG_KEY. The workflow maps OMG_KEY to OMGLOL_KEY inside the script:
env:
OMGLOL_USERNAME: $
OMGLOL_KEY: $A little annoying to have two names for the same secret, but it works — the repo keeps its short name, the script gets a clearer one.
The script reads the Markdown files, pulls front matter, and skips anything that shouldn't go public:
if (draft === true || published === false || frontMatter.permalink === "false") {
continue;
}then builds a plain post object:
posts.push({
file,
title,
url,
date: date.toISOString()
});Normal dated posts get their URL from the file slug:
const slug = file.replace(/\.md$/i, "");
return new URL(`/blog/${slug}/`, siteUrl).toString();and posts with an explicit permalink keep it:
if (permalink && permalink !== "false") {
return new URL(permalink, siteUrl).toString();
}That matters more than it sounds like it should — not every page on an older Eleventy site follows the same URL pattern, and mine definitely doesn't.
Where I actually got it wrong #
The key function is selectPostsToPublish. On the first run it publishes unpublished posts inside the backfill window:
const cutoff = Date.now() - backfillDays * 24 * 60 * 60 * 1000;
return unpublished.filter((post) => new Date(post.date).getTime() >= cutoff);After that it switches to knownUrls:
return posts.filter((post) => !knownUrls.has(post.url) && !publishedUrls.has(post.url));My first pass recorded the six posts I backfilled but never recorded the rest of the archive as "known." So the next run looked at dozens of old posts, saw they'd never been published, and got ready to blast all of them to status.lol. Not great.
The fix was recording a baseline of every known URL, not just the ones I'd posted:
knownUrls: posts.map((post) => post.url)Now the workflow can tell the difference between old posts that were never meant to go to status.lol, recent posts from the backfill, and posts that are genuinely new.
The GitHub Actions side #
The workflow lives at .github/workflows/update-feeds.yml and runs on a schedule, on demand, and whenever blog posts or the script itself change:
on:
schedule:
- cron: "0 23 * * *"
push:
branches:
- main
paths:
- "content/blog/**"
- "scripts/publish-statuslog-posts.mjs"
- ".github/workflows/update-feeds.yml"
workflow_dispatch:I got the path filter wrong at first — only content/blog/** triggered it, so a commit fixing the workflow itself never actually ran the workflow. Adding the script and workflow paths means changes to the integration test themselves.
The publish step:
- name: Publish new blog posts to status.lol
env:
OMGLOL_USERNAME: $
OMGLOL_KEY: $
STATUSLOG_SKIP_MASTODON_POST: "true"
run: npm run statuslog:publishThen it refreshes the local status.lol data:
- name: Refresh status.lol data after publishing
run: curl --fail --location --retry 3 --retry-delay 5 --max-time 30 "https://api.omg.lol/address/$/statuses/" > _data/status.lol.jsonI originally had this backwards, fetching before publishing, which meant the committed data file was always one status behind. Publish first, then refresh.
One of the habits I'm trying to get into is dry runs and once again I'm reminded at how helpful dry runs are instead of just full sending it (no matter how tempting it is).
Dry runs saved me #
STATUSLOG_DRY_RUN=true npm run statuslog:publishBefore I fixed the baseline, this is what it told me:
DRY RUN: 48 blog post(s) ready for Statuslog.Forty-eight! That's when I knew something was wrong — those weren't new posts, that was my entire back catalog getting queued up. After adding knownUrls, the same command reports:
DRY RUN: 0 blog post(s) ready for Statuslog.which is exactly what it should say right after a clean run.
What the state file looks like #
{
"knownUrls": [
"https://www.adamdjbrett.com/blog/example-old-post/",
"https://www.adamdjbrett.com/blog/example-new-post/"
],
"publishedUrls": [
"https://www.adamdjbrett.com/blog/example-new-post/"
],
"statuses": [
{
"blogUrl": "https://www.adamdjbrett.com/blog/example-new-post/",
"title": "Example New Post",
"statusId": "6a4b2efa69ec9",
"statusUrl": "https://status.lol/adjb/6a4b2efa69ec9",
"publishedAt": "2026-07-06T04:28:42.515Z"
}
]
}GitHub Actions commits this file itself, which is what makes the memory durable — the runner is temporary, the repo isn't.
What I'd tell myself before starting #
Start with the smallest working request. Robb Knight's StatusPost showed me the essence of the call without any extra architecture around it — it's just a bearer-token POST to /address/{address}/statuses/.
Don't confuse "not posted yet" with "new." A post can be old and still unposted. Treat every unposted URL as new and you'll eventually flood your status log with your entire archive, which is exactly what almost happened to me.
Refresh anything API-backed after you write to the API, not before. I had this backwards for longer than I'd like to admit.
And let the workflow trigger on its own files. If it only runs when content changes, a broken workflow can sit broken until the next time you happen to publish — too slow when you're touching a live API on a schedule.
Thanks #
Thanks to Robb Knight for [StatusPost(https://rknight.me/alfred-workflows/)] — the small, plain script that made this whole thing click and gave me my epiphany.
Now every new post here shows up on adjb.status.lol within a day of publishing, without me having to touch anything.
After all what is ci if not the friends and mistakes we make along the way.
Tags : 11ty build-awesome eleventy indieweb status.lol omg.lol github-actions automation