Toby Allen

Decoupling Social Post Scheduling From Blog Deploys

· GitHub Actions, Automation, Web Development

Recently I wanted to schedule a run of social posts across Bluesky, Mastodon, and LinkedIn independently of the blog articles they were about - some tied to brand new posts going out over the following week, one just announcing an update to an article that was already live. The GitHub Actions pipeline this blog already had for cross-posting couldn't do either of those things: a social post only ever existed as a side effect of a successful Vercel production deploy.

That coupling made sense when it was built - firing off the deploy's own deployment_status webhook meant the article's URL was guaranteed to be live before any platform tried to unfurl it. The trade-off was that the only thing that could ever trigger a social post was a brand new .mdx file landing in the same deploy, and the timing of the article and the timing of its social post were permanently fused together. In this post I will detail how I split the two apart into an independent queue, and the two separate link-card bugs I found and fixed while I was in there.

The steps this post covers are as follows.

  1. Why cross-posting was originally coupled to a deploy
  2. Splitting the pipeline into an enqueue step and a posting step
  3. The link-card bug hiding in both the LinkedIn and Bluesky code

The Original Coupling

The old workflow, cross-post.yml, ran on exactly one trigger:

on:
  deployment_status:
  workflow_dispatch:
    # ...manual replay inputs
 
jobs:
  cross-post:
    if: |
      github.event_name == 'workflow_dispatch' ||
      (github.event_name == 'deployment_status' &&
       github.event.deployment_status.state == 'success' &&
       github.event.deployment_status.environment == 'Production')

Once a deploy succeeded, it diffed HEAD~1 against HEAD for newly added article files, and for each one, posted its sibling <slug>.social.json copy straight away. This worked fine for the common case - write an article, write its social copy, push, and both go out together - but it had no way to represent "post this on Wednesday, three days after the article ships", let alone "post this, there's no article behind it at all". The HEAD~1 diff was also a little fragile; it only ever looked at the single most recent commit, so a push containing more than one commit could quietly miss an article.

Splitting Enqueue From Posting

The fix was to stop treating "new article" and "social post" as the same event. There's now a content/social-queue/ directory holding one JSON file per pending post, and each file is fully self-contained - it carries the actual copy for every platform rather than a pointer back to an article:

{
  "version": 1,
  "articleUrl": "https://tobytes.com/articles/auth0-session-token-management-options-explained",
  "title": "Auth0 Session and Token Management: Every Option Explained",
  "imageUrl": "https://tobytes.com/articles/auth0-session-token-management-options-explained/opengraph-image",
  "bluesky": "...",
  "mastodon": "...",
  "linkedin": "..."
}

Two GitHub Actions now do the work that one used to:

  • enqueue-social.yml runs on every push touching content/articles/** - whether that push is a direct commit or the scheduled-publish job moving a file across - diffs for newly added articles, and writes a queue item for each one that has social copy sitting beside it.
  • post-social.yml runs on its own 15-minute cron, entirely independent of deploys. It scans the queue for anything due, posts it, and archives it to content/social-queue/posted/ once every platform has succeeded.
git push to main article.mdx + article.social.json Vercel deploy builds, goes live enqueue-social.yml diffs the push, writes a queue item content/social-queue/ self-contained JSON, own publishAt post-social.yml posts whatever's due 15-min cron, independent of deploy Bluesky Mastodon LinkedIn content/social-queue/ posted/ (archive) once every platform succeeds

Neither of these cares whether Vercel has finished deploying. The queue item can also carry its own optional publishAt, separate from the article's, so a post can go out hours or days after (or before) the article it's promoting. And because a queue item doesn't strictly need an articleSlug at all, I can drop a standalone announcement straight into the queue with no article behind it - which is exactly what the "update to an already-live article" post needed, since nothing new was being added to content/articles/ for it to be detected against.

While rebuilding this I noticed something that had nothing to do with scheduling: every post going out to LinkedIn and Bluesky showed up with no header image, even though pasting the exact same link into either platform's own composer produces a rich card with a thumbnail. It's an easy assumption to make - the compose box behaviour makes it look like the platform's API auto-unfurls a link the moment it sees one in the text - and it's wrong for both platforms, for two different reasons.

LinkedIn's old code posted through its deprecated ugcPosts v2 API with shareMediaCategory: "NONE", on the assumption that a link card would just appear. LinkedIn's current Posts API documentation says outright that "the Posts API does not support URL scraping for article post creation" - a thumbnail has to be uploaded first via the Images API and referenced by URN:

const imageUrn = await uploadThumbnail(accessToken, personUrn, ctx.imageUrl);
if (imageUrn) {
  content = {
    article: {
      source: ctx.articleUrl,
      thumbnail: imageUrn,
      title: ctx.title ?? "",
      description: "",
    },
  };
}

Bluesky's bug was a different flavour of the same mistake. The old code built rich-text facets so the URL in a post rendered as a clickable link, and assumed that was also what triggered the unfurl. It isn't - a facet only turns text into a hyperlink. The card comes exclusively from an explicit app.bsky.embed.external record, populated with a thumbnail uploaded separately as a blob:

embed = {
  $type: "app.bsky.embed.external",
  external: {
    uri: ctx.articleUrl,
    title: ctx.title ?? "",
    description: "",
    ...(thumb ? { thumb } : {}),
  },
};

Both APIs are perfectly happy to accept a bare link with no complaint, which is exactly why this had gone unnoticed - nothing errors, the post just quietly ships without a picture. Both fixes fall back to a plain text post if the thumbnail upload fails for any reason, rather than losing the post entirely. When I fired the fix against a real update-announcement post, the thumbnail showed up correctly on both platforms on the first attempt.

Conclusion

The queue, the two new GitHub Actions, and the image fix together replaced roughly 500 lines of the old cross-posting code with about 850 - most of the growth is the LinkedIn API migration and the Bluesky blob-upload logic, not the scheduling itself, which turned out to be the easy part. If you're running a similar git-based cross-poster bolted onto a blog, it's worth actually checking whether your LinkedIn or Bluesky integration attaches a thumbnail, rather than trusting that it does because the compose box does.