Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/auto-merge-blog.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
name: Auto-merge Blog Draft PRs

# When you approve a blog/ draft PR on GitHub, it squash-merges automatically.
# The blog-publish workflow then fires on the push to main and handles LinkedIn.
# The blog-publish workflow then fires on the push to main and posts a note to
# micro.blog, which syndicates to LinkedIn (and any other configured cross-posts).

on:
pull_request_review:
Expand Down
35 changes: 24 additions & 11 deletions .github/workflows/blog-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@ on:
- main
paths:
- 'content/blog/**.md'
workflow_dispatch:
inputs:
post_path:
description: "Override: cross-post a specific file (e.g. content/blog/2026-04-25-devops-matters-more-when-ai-writes-code.md)"
required: false
type: string

jobs:
linkedin:
microblog-crosspost:
runs-on: ubuntu-latest
# Only run if LINKEDIN_ACCESS_TOKEN is set — lets you disable cross-posting
# by removing the secret without touching the workflow
if: ${{ vars.LINKEDIN_ENABLED == 'true' }}
# Kill switch — set the repo variable MICROBLOG_CROSSPOST_ENABLED=true to enable.
# Disable cross-posting by unsetting it.
if: ${{ vars.MICROBLOG_CROSSPOST_ENABLED == 'true' }}

steps:
- name: Checkout
Expand All @@ -23,10 +29,15 @@ jobs:
- name: Find new/changed blog post
id: post
run: |
CHANGED=$(git diff --name-only HEAD~1 HEAD -- 'content/blog/*.md' \
| grep -v '\.gitkeep' | head -1)
if [ -n "${{ inputs.post_path }}" ]; then
CHANGED="${{ inputs.post_path }}"
echo "Using manual override: ${CHANGED}"
else
CHANGED=$(git diff --name-only HEAD~1 HEAD -- 'content/blog/*.md' \
| grep -v '\.gitkeep' | head -1)
echo "Found: ${CHANGED}"
fi
echo "path=${CHANGED}" >> $GITHUB_OUTPUT
echo "Found: ${CHANGED}"

- name: Setup Node.js
if: steps.post.outputs.path != ''
Expand All @@ -40,19 +51,21 @@ jobs:
if: steps.post.outputs.path != ''
run: npm ci --prefix apps/editorial-loop

- name: Generate LinkedIn post + publish
- name: Generate micro.blog note + publish
if: steps.post.outputs.path != ''
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
LINKEDIN_ACCESS_TOKEN: ${{ secrets.LINKEDIN_ACCESS_TOKEN }}
LINKEDIN_PERSON_URN: ${{ secrets.LINKEDIN_PERSON_URN }}
MICROBLOG_APP_TOKEN: ${{ secrets.MICROBLOG_APP_TOKEN }}
MICROBLOG_DESTINATION_UID: ${{ vars.MICROBLOG_DESTINATION_UID }}
POST_PATH: ${{ steps.post.outputs.path }}
BASE_URL: https://doughatcher.com
run: node apps/editorial-loop/publish.js

- name: Summary
if: steps.post.outputs.path != ''
run: |
echo "## LinkedIn Cross-Post" >> $GITHUB_STEP_SUMMARY
echo "## micro.blog Cross-Post" >> $GITHUB_STEP_SUMMARY
echo "Post: \`${{ steps.post.outputs.path }}\`" >> $GITHUB_STEP_SUMMARY
echo "Published at: $(date -u '+%Y-%m-%d %H:%M UTC')" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Micro.blog will syndicate to any cross-post destinations (LinkedIn, etc.) configured on the account." >> $GITHUB_STEP_SUMMARY
Original file line number Diff line number Diff line change
@@ -1,30 +1,35 @@
/**
* linkedin-poster.js
* microblog-poster.js
*
* Generates a LinkedIn-optimized excerpt from a blog post via Claude,
* then posts it to LinkedIn via the UGC Posts API.
* Generates a cross-post excerpt from a blog post via Claude, then posts it
* to micro.blog as a note via the Micropub API. Micro.blog displays the note
* on the timeline at doughatcher.com/ and syndicates it to any cross-post
* destinations configured in the account (LinkedIn, Mastodon, etc.).
*
* Required secrets:
* LINKEDIN_ACCESS_TOKEN — OAuth 2.0 token with w_member_social scope
* LINKEDIN_PERSON_URN — your LinkedIn member URN, e.g. "ACoAA..."
* (find at linkedin.com/in/<yourslug>/ → Page source → "entityUrn")
* Required env:
* MICROBLOG_APP_TOKEN — app token from https://micro.blog/account/apps
*
* Note: LinkedIn access tokens expire after 60 days. Refresh via the OAuth flow
* and update the LINKEDIN_ACCESS_TOKEN secret before it lapses.
* Optional env:
* MICROBLOG_DESTINATION_UID — mp-destination UID (only needed if the token
* has access to more than one site; omit otherwise)
*/

import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic();

export async function generateLinkedInPost(postContent, postUrl) {
// Use pre-generated linkedin_copy from frontmatter if present
export async function generateMicroblogPost(postContent, postUrl) {
// Use pre-generated linkedin_copy from frontmatter if present.
// The field is still called linkedin_copy because LinkedIn is the
// primary syndication target — micro.blog is the transport.
const pregenerated = extractLinkedInCopy(postContent);
if (pregenerated) {
return pregenerated.replace('{{url}}', postUrl).replace(/Full post: \{\{url\}\}/g, `Full post: ${postUrl}`).trim();
return pregenerated
.replace('{{url}}', postUrl)
.replace(/Full post: \{\{url\}\}/g, `Full post: ${postUrl}`)
.trim();
}

// Fallback: generate fresh from the full post content
const response = await client.messages.create({
model: 'claude-opus-4-6',
max_tokens: 1024,
Expand Down Expand Up @@ -53,7 +58,6 @@ Write only the LinkedIn post text. No commentary, no alternatives.`,
return response.content[0].text.trim();
}

// Extract linkedin_copy YAML block scalar from frontmatter
function extractLinkedInCopy(content) {
const fmMatch = content.match(/^---\n([\s\S]+?)\n---/);
if (!fmMatch) return null;
Expand All @@ -62,42 +66,30 @@ function extractLinkedInCopy(content) {
return copyMatch[1].replace(/^ /gm, '').trim();
}

export async function postToLinkedIn(text) {
const token = process.env.LINKEDIN_ACCESS_TOKEN;
const personUrn = process.env.LINKEDIN_PERSON_URN;
export async function postToMicroblog(text) {
const token = process.env.MICROBLOG_APP_TOKEN;
if (!token) throw new Error('MICROBLOG_APP_TOKEN must be set');

if (!token || !personUrn) {
throw new Error('LINKEDIN_ACCESS_TOKEN and LINKEDIN_PERSON_URN must be set');
const params = new URLSearchParams();
params.append('h', 'entry');
params.append('content', text);
if (process.env.MICROBLOG_DESTINATION_UID) {
params.append('mp-destination', process.env.MICROBLOG_DESTINATION_UID);
}

const body = {
author: `urn:li:person:${personUrn}`,
lifecycleState: 'PUBLISHED',
specificContent: {
'com.linkedin.ugc.ShareContent': {
shareCommentary: { text },
shareMediaCategory: 'NONE',
},
},
visibility: {
'com.linkedin.ugc.MemberNetworkVisibility': 'PUBLIC',
},
};

const res = await fetch('https://api.linkedin.com/v2/ugcPosts', {
const res = await fetch('https://micro.blog/micropub', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: JSON.stringify(body),
body: params.toString(),
});

if (!res.ok) {
const detail = await res.text();
throw new Error(`LinkedIn API ${res.status}: ${detail}`);
throw new Error(`Micropub ${res.status}: ${detail}`);
}

return res.json();
return { location: res.headers.get('location') || null };
}
2 changes: 1 addition & 1 deletion apps/editorial-loop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "editorial-loop",
"version": "1.0.0",
"description": "Weekly editorial engine — reads vault through strategy lens, generates ideas via Claude, opens Draft PRs, cross-posts to LinkedIn",
"description": "Weekly editorial engine — reads vault through strategy lens, generates ideas via Claude, opens Draft PRs, cross-posts to micro.blog (syndicated to LinkedIn)",
"type": "module",
"scripts": {
"generate": "node index.js",
Expand Down
26 changes: 14 additions & 12 deletions apps/editorial-loop/publish.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
/**
* publish.js — LinkedIn cross-post entry point
* publish.js — micro.blog cross-post entry point
*
* Called by the blog-publish workflow when a blog post is merged to main.
* Reads the post file from POST_PATH env var, generates a LinkedIn excerpt
* via Claude, and posts it.
* Reads the post file from POST_PATH env var, generates an excerpt via Claude
* (or pulls pre-generated linkedin_copy from frontmatter), and posts it as a
* note to micro.blog via Micropub. Micro.blog renders the note on the timeline
* at doughatcher.com/ and syndicates it to any cross-post destinations
* (LinkedIn, Mastodon, etc.) configured on the account.
*/

import fs from 'fs';
import { generateLinkedInPost, postToLinkedIn } from './lib/linkedin-poster.js';
import { generateMicroblogPost, postToMicroblog } from './lib/microblog-poster.js';

const POST_PATH = process.env.POST_PATH;
const BASE_URL = process.env.BASE_URL || 'https://doughatcher.com';
Expand All @@ -19,22 +22,21 @@ async function main() {

// Derive public URL from file path: content/blog/2026-04-18-my-slug.md → /blog/my-slug/
const filename = POST_PATH.split('/').pop().replace('.md', '');
// Strip leading date prefix (YYYY-MM-DD-)
const slug = filename.replace(/^\d{4}-\d{2}-\d{2}-/, '');
const postUrl = `${BASE_URL}/blog/${slug}/`;

console.log(`📖 Post: ${POST_PATH}`);
console.log(`🔗 URL: ${postUrl}`);

console.log('🧠 Generating LinkedIn post via Claude...');
const linkedInText = await generateLinkedInPost(content, postUrl);
console.log('--- LinkedIn post preview ---');
console.log(linkedInText);
console.log('🧠 Generating micro.blog note...');
const noteText = await generateMicroblogPost(content, postUrl);
console.log('--- micro.blog note preview ---');
console.log(noteText);
console.log('---');

console.log('📤 Posting to LinkedIn...');
const result = await postToLinkedIn(linkedInText);
console.log(`✅ Posted: ${result.id}`);
console.log('📤 Posting to micro.blog...');
const result = await postToMicroblog(noteText);
console.log(`✅ Posted: ${result.location || '(no Location header returned)'}`);
}

main().catch(err => {
Expand Down
Loading