diff --git a/.github/workflows/auto-merge-blog.yml b/.github/workflows/auto-merge-blog.yml index a6e0601..750573f 100644 --- a/.github/workflows/auto-merge-blog.yml +++ b/.github/workflows/auto-merge-blog.yml @@ -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: diff --git a/.github/workflows/blog-publish.yml b/.github/workflows/blog-publish.yml index 2e59c52..c03ff71 100644 --- a/.github/workflows/blog-publish.yml +++ b/.github/workflows/blog-publish.yml @@ -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 @@ -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 != '' @@ -40,12 +51,12 @@ 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 @@ -53,6 +64,8 @@ jobs: - 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 diff --git a/apps/editorial-loop/lib/linkedin-poster.js b/apps/editorial-loop/lib/microblog-poster.js similarity index 50% rename from apps/editorial-loop/lib/linkedin-poster.js rename to apps/editorial-loop/lib/microblog-poster.js index 611ede9..46c87d1 100644 --- a/apps/editorial-loop/lib/linkedin-poster.js +++ b/apps/editorial-loop/lib/microblog-poster.js @@ -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// → 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, @@ -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; @@ -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 }; } diff --git a/apps/editorial-loop/package.json b/apps/editorial-loop/package.json index 719a511..53878e9 100644 --- a/apps/editorial-loop/package.json +++ b/apps/editorial-loop/package.json @@ -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", diff --git a/apps/editorial-loop/publish.js b/apps/editorial-loop/publish.js index 6684ead..2112bda 100644 --- a/apps/editorial-loop/publish.js +++ b/apps/editorial-loop/publish.js @@ -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'; @@ -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 => {