Automating Social Media Posting for My Jekyll Blog
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:
- Rebuild job - Creates empty commit to trigger Jekyll rebuild
- 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):
extract-social-content.js- Parses markdown, extracts Liquid comments, builds blog URLspost-to-x.js- X API client using twitter-api-v2 library, chains tweets into threadspost-to-linkedin.js- LinkedIn API client using axios, posts content then adds comment with blog URLpost-to-social-media.js- Main orchestrator, runs both platforms in parallelmark-post-published.js- Adds PUBLISHED timestamps to prevent duplicate posting
Dependencies:
gray-matter- Parse markdown front mattertwitter-api-v2- X/Twitter OAuth 1.0a and v2 APIaxios- 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
- Applied at https://developer.x.com/en/portal/dashboard
- Created app in Developer Portal
- Generated API Key, API Secret, Access Token, Access Token Secret
- Free tier limits: 1,500 posts/month, 17 posts/24 hours (way more than needed for weekly posts)
LinkedIn Developer App
- Created app at https://www.linkedin.com/developers/apps
- Requested “Sign In with LinkedIn” and “Share on LinkedIn” permissions
- Generated OAuth 2.0 Access Token
- 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:
- Feature spec:
specs/001-automating-sharing-post-to-social-media.md - Scripts:
scripts/directory - Workflow:
.github/workflows/schedule-rebuild.yml - This post’s markdown source (with social media content):
_posts/2025-11-18-automating-blog-social-media-posting.md