I wanted every new blog post to automatically show up on X (Twitter) and LinkedIn without me having to manually copy-paste content. The constraint was simple: no paid services, no external hosting, everything runs on GitHub Actions free tier.

Here’s what I built and how it works.

The Problem

Writing a blog post is one thing, promoting it is another. I’d write a post, publish it, then need to:

  • Draft a LinkedIn post with the right format and hashtags
  • Create an X thread breaking down the key points
  • Remember to post the blog link in a LinkedIn comment (not the main post, because algorithm penalties)
  • Post the link in the last tweet of the X thread

This takes 15-20 minutes per post and it’s easy to forget or delay. I wanted it automatic.

The Constraints

I’m already using GitHub Pages for hosting, which means GitHub Actions is available. I didn’t want to:

  • Pay for IFTTT Pro ($2.49/month minimum) or Zapier
  • Set up external hosting for automation scripts
  • Use services that might shut down or change pricing

The entire solution needed to run on free tiers: GitHub Actions, X API, LinkedIn API.

The Approach

Content Storage

Social media content is pre-written in Liquid comment blocks at the end of each blog post. These comments are completely removed during Jekyll’s build, they never appear in the published HTML.

{% comment %}
## LinkedIn Post
[Pre-written post content with hashtags]

---

## X/Twitter Thread
Tweet 1: [Content]
Tweet 2: [Content]
...
{% endcomment %}

This keeps the content alongside the post, version controlled, and easy to edit. No separate database, no external CMS.

Architecture

The automation runs as a GitHub Actions job that triggers after the scheduled post publishing workflow completes every Tuesday at 9 AM EST:

  1. Rebuild job - Creates empty commit to trigger Jekyll rebuild
  2. Post-to-social-media job - Runs after rebuild:
    • Finds posts published in last 7 days
    • Extracts social content from Liquid comments
    • Posts to X and LinkedIn in parallel

Implementation Stack

Node.js scripts (5 modules, ~650 lines total):

Dependencies:

  • gray-matter - Parse markdown front matter
  • twitter-api-v2 - X/Twitter OAuth 1.0a and v2 API
  • axios - HTTP requests for LinkedIn OAuth 2.0 API

GitHub Secrets (5 credentials):

  • X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_TOKEN_SECRET
  • LINKEDIN_ACCESS_TOKEN

Key Design Decisions

Platform independence: Each platform operates independently. LinkedIn failure doesn’t affect X posting, X failure doesn’t affect LinkedIn. Workflow always exits with success even if one platform fails.

No content generation: The system extracts and posts pre-written content, it doesn’t generate anything. Content format guidelines are documented separately (in AGENTS.md), the automation just parses and posts.

7-day window: The workflow looks for posts dated within the last 7 days. This means if you publish multiple posts in one week, they all get posted on Tuesday. If a post’s social posting fails, the next Tuesday retries it.

Link placement strategy:

  • LinkedIn: Main post has no link, first comment contains blog URL (avoids 25-40% algorithm reach penalty)
  • X/Twitter: Link only in last tweet of thread (algorithm suppresses posts with links in main content)

Duplicate prevention: After successfully posting to a platform, the workflow adds a PUBLISHED: [timestamp] flag to that platform’s section in the Liquid comment block and commits it back to the repo. The extraction module checks for these flags and skips already-posted content, so running the workflow multiple times never creates duplicates.

The Workflow

Here’s the core GitHub Actions logic:

post-to-social-media:
  runs-on: ubuntu-latest
  needs: rebuild

  steps:
  - name: Checkout repository
    uses: actions/checkout@v4

  - name: Setup Node.js
    uses: actions/setup-node@v4
    with:
      node-version: '20'

  - name: Install dependencies
    run: |
      cd scripts
      npm install

  - name: Find and post newly published posts
    env:
      X_API_KEY: $
      X_API_SECRET: $
      X_ACCESS_TOKEN: $
      X_ACCESS_TOKEN_SECRET: $
      LINKEDIN_ACCESS_TOKEN: $
    run: |
      # Find posts from last 7 days
      for post in _posts/*.md; do
        POST_DATE=$(basename "$post" | grep -oE '^[0-9]{4}-[0-9]{2}-[0-9]{2}')
        if [[ "$POST_DATE" >= "$WEEK_AGO" && "$POST_DATE" <= "$CURRENT_DATE" ]]; then
          node scripts/post-to-social-media.js "$post"
        fi
      done

  - name: Commit published flags
    run: |
      git config user.name "GitHub Actions Bot"
      git config user.email "actions@github.com"

      if git diff --quiet _posts/; then
        echo "No changes to commit"
      else
        git pull --rebase origin main
        git add _posts/
        git commit -m "chore: mark social media posts as published [skip ci]"
        git push
      fi

Content Extraction Logic

The extraction module uses regex with matchAll() to find all Liquid comment blocks, then takes the last match (the actual social content at the end of the post, not any template examples in the post content):

const commentRegex = /{%\s*comment\s*%}([\s\S]*?){%\s*endcomment\s*%}/gi;
const matches = Array.from(markdownContent.matchAll(commentRegex));
const lastMatch = matches[matches.length - 1]; // Get last block

Then it parses the LinkedIn and X/Twitter sections from that block:

For X/Twitter:

  • Extracts individual tweets labeled “Tweet 1”, “Tweet 2”, etc.
  • Maintains order for threading
  • Removes “INSTRUCTIONS” section

For LinkedIn:

  • Extracts main post content (everything before “INSTRUCTIONS”)
  • Posts content, then immediately adds first comment with blog URL

Both platforms ignore the INSTRUCTIONS sections I include in the Liquid comments for manual posting reference.

API Setup

X (Twitter) Developer Account

  1. Applied at https://developer.x.com/en/portal/dashboard
  2. Created app in Developer Portal
  3. Generated API Key, API Secret, Access Token, Access Token Secret
  4. Free tier limits: 1,500 posts/month, 17 posts/24 hours (way more than needed for weekly posts)

LinkedIn Developer App

  1. Created app at https://www.linkedin.com/developers/apps
  2. Requested “Sign In with LinkedIn” and “Share on LinkedIn” permissions
  3. Generated OAuth 2.0 Access Token
  4. Token expires every 2 months (manual refresh required)

GitHub Secrets

Added all five credentials in repository Settings → Secrets and variables → Actions. The workflow injects them as environment variables, never logged or exposed.

Testing

Initial test failed with a bash syntax error in the date comparison logic. The fix was changing:

if [[ "$POST_DATE" >= "$WEEK_AGO" && "$POST_DATE" <= "$CURRENT_DATE" ]]; then

To:

if [[ "$POST_DATE" > "$WEEK_AGO" || "$POST_DATE" == "$WEEK_AGO" ]] && [[ "$POST_DATE" < "$CURRENT_DATE" || "$POST_DATE" == "$CURRENT_DATE" ]]; then

The >= and <= operators weren’t valid in that bash conditional syntax. After fixing that, the workflow ran successfully and posted to both platforms.

What This Enables

Now I can write a blog post, add the social media content in Liquid comments at the end, set a future date in the front matter, and commit. On Tuesday morning, the post goes live on the blog and automatically appears on X and LinkedIn within minutes.

No manual steps, no remembering to promote, no context switching from writing to marketing.

The Caveats

LinkedIn token maintenance: The OAuth token expires every 2 months. I need to manually refresh it and update the GitHub Secret. This is acceptable overhead for a free solution.

Content must be pre-written: This isn’t AI-generated social posts, it’s automation of pre-written content. I still write the LinkedIn post and X thread myself, but I write them once alongside the blog post instead of copy-pasting later.

Manual intervention for failures: If a post fails to publish (API error, rate limit, etc.), the workflow exits successfully but the post won’t have the PUBLISHED flag. Next Tuesday it’ll retry automatically, which is usually what you want. For immediate retry, you can manually trigger the workflow from the Actions tab.

Cost

Zero. Everything runs on free tiers:

  • GitHub Actions: Free for public repositories
  • X API: Free tier (1,500 posts/month)
  • LinkedIn API: Free for personal posting
  • GitHub Pages: Free hosting

Time Investment vs Savings

Implementation: About 2 hours total (research, coding, testing)

Time saved per post: 15-20 minutes

Break-even: After ~7 posts, or less than 2 months at weekly cadence

But the real value isn’t just time saved, it’s consistency. Every post gets promoted, on schedule, every time. No forgetting, no delaying because I’m busy.

Code

The full implementation is in the blog repository: