Skip to content
All writing
Technical · 10 min

The Machine That Posts for Me: How This Blog Publishes and Promotes Itself

I don't like posting to social media, so I built a pipeline that publishes my blog and promotes it across LinkedIn, Bluesky, and X — on a schedule, in my voice, with a linter that won't let it sound like a robot. Here's the whole system, including the two automated feeds I had to stop from colliding.

Contents

You’re reading this because a machine decided it was a good time to post it. I didn’t open Buffer, didn’t write a caption this morning, didn’t pick the time. Some weeks back I approved a draft, and the rest happened on its own: the post went live on its scheduled date, a script wrote and sent the announcement to LinkedIn, Bluesky, and X, and a notification told me it was done. The system you’re reading about is the system that put this in front of you.

I’ll be honest about the reason it exists: I don’t like posting to social media. The performance of it, the cadence, the little hit of checking how it did — none of it appeals to me. But I do want the writing to find people, and the modern deal is that good work no longer markets itself. So I made a different deal with myself. I’d build a pipeline that does the posting, on a schedule that actually works, in my voice — and that I never have to babysit. The interesting part, it turned out, wasn’t the posting. It was everything I had to build so that I could trust it without watching it.

This is the layer underneath the writing. If you want the story of how this site got built at all, that’s the Wix-to-Astro rebuild; if you want the one weird live endpoint on it, that’s how the Ask Me bot works. This post is the publishing and distribution machine that sits on top of both.

Publishing happens at build time, not in a CMS

The first decision shaped everything else: there is no admin panel. A post is a markdown file in a git repository, and “publishing” is just a build that decides whether to include it.

Every post carries a status that moves through three states — draft, review, approved — and a pubDate. A post is live only when both are true: the status is approved and the pubDate has arrived. That logic lives in exactly one place, a small isPublished() function that every part of the site calls, so there’s no chance the homepage and the RSS feed disagree about what’s public. Anything short of approved is invisible, full stop. A half-written draft can’t leak, and neither can a finished one I haven’t blessed yet.

Here’s the entire publish gate. It lives in one function that every part of the site calls:

export const isPublished = (post) =>
  post.data.status === 'approved' && post.data.pubDate <= new Date();

That’s the whole rule — there’s no second copy of it to drift out of sync. The post itself just carries a few lines of metadata at the top:

status: approved        # draft → review → approved
pubDate: 2026-09-08     # hidden until this morning, then it just appears
platforms: [linkedin, bluesky, twitter]   # omit to reach every connected network

(The snippets here are trimmed for readability, but they’re honestly close to the real thing.)

That gives me something I wanted badly: a post can be completely done, scheduled weeks out, and still sit silent until its morning. The way it surfaces on its own is almost dumb in its simplicity. A GitHub Actions cron job runs once a day and pings a Cloudflare deploy hook — it just rebuilds the whole site. During that rebuild, a post whose pubDate has finally arrived passes the isPublished check for the first time and appears. No scheduler watching a clock, no server staying up to flip a flag at midnight. The site simply rebuilds every morning and re-asks the question “what’s public now?” The calendar does the rest.

The whole scheduler is one line of cron:

on:
  schedule:
    - cron: '0 12 * * *'   # rebuild every morning at noon UTC

No long-running process waits for midnight to flip a switch. The morning rebuild is the entire mechanism.

One post, three networks, no copy-paste

Once a post goes live, it needs to get announced. I push to LinkedIn, Bluesky, and X, and I refuse to write the same thing three times.

The announcement is a script that runs right after the daily rebuild. It looks for any post that just crossed the line into “live” and hasn’t been announced before, and it fans the announcement out to every network I have connected. The piece I’m happiest with: it discovers the networks itself. I give it one API key for Buffer (the tool that actually talks to each social platform), and it asks Buffer which channels exist and what service each one is. Connect a new network in Buffer and the pipeline starts posting there with zero code or config changes. When I added X late one afternoon, the next run just picked it up.

Each network gets a caption sized to its own rules. LinkedIn gets the long version — a few real lines with the link near the end, because LinkedIn quietly punishes early outbound links. Bluesky and X get a short version that has to fit in 300 and 280 characters. When the short caption is too long, the script trims the title and never the URL, because a post without its link is just noise.

The sizing is really just a lookup with a trim rule attached:

const LIMITS = { linkedin: 3000, bluesky: 300, twitter: 280 };
// if a short caption overflows, shorten the title — never the link

I can hand-write the captions in the post’s own frontmatter, or let it generate them; either way, the same checks run before anything leaves the building.

The hard part: two feeds that must never collide

Here’s the problem that ate the most time, and it’s the one I’m proudest of solving, because it’s invisible when it works.

There are actually two automated feeds, not one. The first announces new posts the day they go live. The second is an evergreen drip: once a week it reaches back into the archive and re-shares an older post, oldest first, each one exactly once, until the back catalogue is exhausted — then it stops. New writing gets its moment; old writing keeps working. Good in theory.

The trap is that both feeds aim at the same prime-time slot, and neither knows about the other. Picture a week where a new post publishes and the weekly drip fires. Without coordination, you get two of your own posts landing an afternoon apart — and the platforms throttle how often they’ll show your stuff to the same person, so back-to-back posts cannibalize each other’s reach. The fix isn’t to post more. It’s to never post twice on top of yourself.

So the evergreen feed yields. Before it drips, it checks two things. It asks Buffer whether anything is already scheduled in the next few days — that catches manual posts and anything already queued. And it reads ahead in my own content calendar: if an approved post is due to publish within the next several days, its announcement is imminent, so the drip steps aside for the week. The post it would have shared just stays at the front of the line and goes out next time.

The two feeds run on their own schedules:

- cron: '30 12 * * *'   # announce new posts, just after the morning rebuild
- cron: '0 14 * * 1'    # evergreen drip — Mondays only

And the drip reads the calendar before it fires, so it can step aside:

// don't drip if a fresh post is about to announce itself
const announceImminent = approvedPosts.some(
  (p) => !alreadyAnnounced(p) && publishesWithin(p, DAYS(5)),
);
if (announceImminent || alreadyScheduledSoon()) skipThisWeek();

The two feeds finally know about each other, and the result is the thing you never notice: a steady cadence, never a pile-up.

There’s a smaller, related discipline in the scheduling itself. Posts go out in the mid-week afternoon window when my audience is actually around, and the time math is daylight-saving-aware so it doesn’t drift an hour twice a year. That same gap between “scheduled” and “sent” is also my undo button — there’s a window where I can still kill a post in Buffer if I change my mind.

The linter that won’t let it sound like a robot

This is the guardrail I’d recommend to anyone automating their own voice: before any caption is sent, it runs through a filter that rejects the tells of machine-written text. The clichés — “game-changer,” “delve into,” “in today’s fast-paced world,” “a testament to” — are on a blocklist, and a caption containing one fails the check instead of going out. The same pass catches empty captions, leftover placeholder text, and anything over a network’s character limit.

The check is exactly as blunt as it sounds:

const SLOP = ['game-changer', 'delve into', "in today's fast-paced world", 'a testament to'];
if (SLOP.some((phrase) => caption.toLowerCase().includes(phrase))) reject(caption);

It’s a blunt instrument and that’s the point. The whole pipeline exists so I don’t have to be in the loop on every post, which means the one thing it can’t be allowed to do is quietly start talking like a press release on my behalf. The machine can schedule, size, and send. It does not get to choose the words and have them sound like nobody. If a caption reads like it could’ve been generated by ten thousand other accounts, I’d rather it never leave. (Why a system tuned to please you is genuinely dangerous, not just bland, is its own post.)

The boring parts that make it trustworthy

A pipeline you don’t watch has to be honest with you, so most of the unglamorous work went into exactly that.

Every send is recorded in a small ledger committed back to the repository, so nothing gets posted twice and a missed day heals itself on the next run. When the script does something — schedules a post, skips a week, hits an error — it opens a GitHub issue telling me what happened, so the only time I hear from it is when there’s something to know. It fails safe: if it can’t reach a network, it reports that one network and still posts the rest, rather than blocking everything. And there are dry-run modes I can trigger by hand — one validates that all my channels are connected without posting anything, one sends a single deletable test post end to end, one lists what’s currently scheduled versus already sent. I used all three while building it, and I still run the validation check whenever I touch the config.

None of this is clever. It’s the difference between automation I’d actually leave running and a clever script I’d quietly stop trusting after the first surprise.

What I deliberately kept manual

For all of that, there’s one thing I refused to automate: the decision that a post is ready. The status only moves to approved when I’ve read the whole thing and moved it there myself. The pipeline does the tedious, repetitive, easy-to-get-wrong work — the scheduling, the sizing, the sending, the not-double-posting — and it stops cold at the one place where judgment lives. That line, between the work a machine should own and the call a person should keep, is the same one I try to hold everywhere I build with these tools. The automation earns its keep precisely because it knows where to stop.

So that’s the machine, with the hood up. It published this post on its own date, wrote three captions in three sizes, picked an afternoon when you might actually be around, checked that none of it sounded like a bot, made sure it wasn’t stepping on another post, and told me when it was done. And in a few months, when the evergreen drip has worked its way back through the archive, it’ll quietly share this one again — a post about the system, distributed by the system, for the second time, without me lifting a finger.

That was the whole idea.

Share LinkedInXBlueskyReddit