Skip to content

Commit 666511c

Browse files
authored
feat(blog-publish): route LinkedIn cross-post via micro.blog Micropub (#45)
Replace the direct LinkedIn UGC Posts API path with a Micropub POST to micro.blog. The note lands on doughatcher.com/ (microblog timeline) and micro.blog syndicates it to LinkedIn (and any other configured cross-post destinations). Why: LinkedIn access tokens expire every 60 days and require manual OAuth refresh. Micro.blog app tokens don't expire, and the user already has LinkedIn configured as a cross-post destination there. Changes: - new apps/editorial-loop/lib/microblog-poster.js (Micropub client) - apps/editorial-loop/publish.js now imports microblog-poster - delete apps/editorial-loop/lib/linkedin-poster.js (unused) - blog-publish.yml: env now MICROBLOG_APP_TOKEN + optional MICROBLOG_DESTINATION_UID, gated on vars.MICROBLOG_CROSSPOST_ENABLED - pre-generated linkedin_copy frontmatter is still used verbatim — field name preserved since LinkedIn is still the syndication target Required GitHub config to enable: - secret MICROBLOG_APP_TOKEN (from https://micro.blog/account/apps) - var MICROBLOG_CROSSPOST_ENABLED=true - var MICROBLOG_DESTINATION_UID (optional; only if token sees multiple sites)
1 parent 165e0e9 commit 666511c

5 files changed

Lines changed: 72 additions & 64 deletions

File tree

.github/workflows/auto-merge-blog.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
name: Auto-merge Blog Draft PRs
22

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

67
on:
78
pull_request_review:

.github/workflows/blog-publish.yml

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,19 @@ on:
66
- main
77
paths:
88
- 'content/blog/**.md'
9+
workflow_dispatch:
10+
inputs:
11+
post_path:
12+
description: "Override: cross-post a specific file (e.g. content/blog/2026-04-25-devops-matters-more-when-ai-writes-code.md)"
13+
required: false
14+
type: string
915

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

1723
steps:
1824
- name: Checkout
@@ -23,10 +29,15 @@ jobs:
2329
- name: Find new/changed blog post
2430
id: post
2531
run: |
26-
CHANGED=$(git diff --name-only HEAD~1 HEAD -- 'content/blog/*.md' \
27-
| grep -v '\.gitkeep' | head -1)
32+
if [ -n "${{ inputs.post_path }}" ]; then
33+
CHANGED="${{ inputs.post_path }}"
34+
echo "Using manual override: ${CHANGED}"
35+
else
36+
CHANGED=$(git diff --name-only HEAD~1 HEAD -- 'content/blog/*.md' \
37+
| grep -v '\.gitkeep' | head -1)
38+
echo "Found: ${CHANGED}"
39+
fi
2840
echo "path=${CHANGED}" >> $GITHUB_OUTPUT
29-
echo "Found: ${CHANGED}"
3041
3142
- name: Setup Node.js
3243
if: steps.post.outputs.path != ''
@@ -40,19 +51,21 @@ jobs:
4051
if: steps.post.outputs.path != ''
4152
run: npm ci --prefix apps/editorial-loop
4253

43-
- name: Generate LinkedIn post + publish
54+
- name: Generate micro.blog note + publish
4455
if: steps.post.outputs.path != ''
4556
env:
4657
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
47-
LINKEDIN_ACCESS_TOKEN: ${{ secrets.LINKEDIN_ACCESS_TOKEN }}
48-
LINKEDIN_PERSON_URN: ${{ secrets.LINKEDIN_PERSON_URN }}
58+
MICROBLOG_APP_TOKEN: ${{ secrets.MICROBLOG_APP_TOKEN }}
59+
MICROBLOG_DESTINATION_UID: ${{ vars.MICROBLOG_DESTINATION_UID }}
4960
POST_PATH: ${{ steps.post.outputs.path }}
5061
BASE_URL: https://doughatcher.com
5162
run: node apps/editorial-loop/publish.js
5263

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

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

1819
const client = new Anthropic();
1920

20-
export async function generateLinkedInPost(postContent, postUrl) {
21-
// Use pre-generated linkedin_copy from frontmatter if present
21+
export async function generateMicroblogPost(postContent, postUrl) {
22+
// Use pre-generated linkedin_copy from frontmatter if present.
23+
// The field is still called linkedin_copy because LinkedIn is the
24+
// primary syndication target — micro.blog is the transport.
2225
const pregenerated = extractLinkedInCopy(postContent);
2326
if (pregenerated) {
24-
return pregenerated.replace('{{url}}', postUrl).replace(/Full post: \{\{url\}\}/g, `Full post: ${postUrl}`).trim();
27+
return pregenerated
28+
.replace('{{url}}', postUrl)
29+
.replace(/Full post: \{\{url\}\}/g, `Full post: ${postUrl}`)
30+
.trim();
2531
}
2632

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

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

65-
export async function postToLinkedIn(text) {
66-
const token = process.env.LINKEDIN_ACCESS_TOKEN;
67-
const personUrn = process.env.LINKEDIN_PERSON_URN;
69+
export async function postToMicroblog(text) {
70+
const token = process.env.MICROBLOG_APP_TOKEN;
71+
if (!token) throw new Error('MICROBLOG_APP_TOKEN must be set');
6872

69-
if (!token || !personUrn) {
70-
throw new Error('LINKEDIN_ACCESS_TOKEN and LINKEDIN_PERSON_URN must be set');
73+
const params = new URLSearchParams();
74+
params.append('h', 'entry');
75+
params.append('content', text);
76+
if (process.env.MICROBLOG_DESTINATION_UID) {
77+
params.append('mp-destination', process.env.MICROBLOG_DESTINATION_UID);
7178
}
7279

73-
const body = {
74-
author: `urn:li:person:${personUrn}`,
75-
lifecycleState: 'PUBLISHED',
76-
specificContent: {
77-
'com.linkedin.ugc.ShareContent': {
78-
shareCommentary: { text },
79-
shareMediaCategory: 'NONE',
80-
},
81-
},
82-
visibility: {
83-
'com.linkedin.ugc.MemberNetworkVisibility': 'PUBLIC',
84-
},
85-
};
86-
87-
const res = await fetch('https://api.linkedin.com/v2/ugcPosts', {
80+
const res = await fetch('https://micro.blog/micropub', {
8881
method: 'POST',
8982
headers: {
9083
Authorization: `Bearer ${token}`,
91-
'Content-Type': 'application/json',
92-
'X-Restli-Protocol-Version': '2.0.0',
84+
'Content-Type': 'application/x-www-form-urlencoded',
9385
},
94-
body: JSON.stringify(body),
86+
body: params.toString(),
9587
});
9688

9789
if (!res.ok) {
9890
const detail = await res.text();
99-
throw new Error(`LinkedIn API ${res.status}: ${detail}`);
91+
throw new Error(`Micropub ${res.status}: ${detail}`);
10092
}
10193

102-
return res.json();
94+
return { location: res.headers.get('location') || null };
10395
}

apps/editorial-loop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "editorial-loop",
33
"version": "1.0.0",
4-
"description": "Weekly editorial engine — reads vault through strategy lens, generates ideas via Claude, opens Draft PRs, cross-posts to LinkedIn",
4+
"description": "Weekly editorial engine — reads vault through strategy lens, generates ideas via Claude, opens Draft PRs, cross-posts to micro.blog (syndicated to LinkedIn)",
55
"type": "module",
66
"scripts": {
77
"generate": "node index.js",

apps/editorial-loop/publish.js

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
/**
2-
* publish.js — LinkedIn cross-post entry point
2+
* publish.js — micro.blog cross-post entry point
33
*
44
* Called by the blog-publish workflow when a blog post is merged to main.
5-
* Reads the post file from POST_PATH env var, generates a LinkedIn excerpt
6-
* via Claude, and posts it.
5+
* Reads the post file from POST_PATH env var, generates an excerpt via Claude
6+
* (or pulls pre-generated linkedin_copy from frontmatter), and posts it as a
7+
* note to micro.blog via Micropub. Micro.blog renders the note on the timeline
8+
* at doughatcher.com/ and syndicates it to any cross-post destinations
9+
* (LinkedIn, Mastodon, etc.) configured on the account.
710
*/
811

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

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

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

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

29-
console.log('🧠 Generating LinkedIn post via Claude...');
30-
const linkedInText = await generateLinkedInPost(content, postUrl);
31-
console.log('--- LinkedIn post preview ---');
32-
console.log(linkedInText);
31+
console.log('🧠 Generating micro.blog note...');
32+
const noteText = await generateMicroblogPost(content, postUrl);
33+
console.log('--- micro.blog note preview ---');
34+
console.log(noteText);
3335
console.log('---');
3436

35-
console.log('📤 Posting to LinkedIn...');
36-
const result = await postToLinkedIn(linkedInText);
37-
console.log(`✅ Posted: ${result.id}`);
37+
console.log('📤 Posting to micro.blog...');
38+
const result = await postToMicroblog(noteText);
39+
console.log(`✅ Posted: ${result.location || '(no Location header returned)'}`);
3840
}
3941

4042
main().catch(err => {

0 commit comments

Comments
 (0)