Automating Blog Cross-posting to DEV and X with GitHub Actions

githubactions automation devto astro
... views likes

Automating Blog Cross-posting to Dev.to and X with GitHub Actions

Publishing a blog post shouldn’t require manually copying content to multiple platforms. I built a GitHub Actions workflow that deploys to Cloudflare, cross-posts to Dev.to, and posts to X—all with one click.

The Problem

Every time I publish a blog post, I need to:

  1. Deploy to Cloudflare Workers
  2. Copy the content to Dev.to (with proper canonical URL)
  3. Post to X about it

Doing this manually is tedious and error-prone. I wanted a single workflow that handles everything.

Architecture Overview

publish-blog-post.yml (GitHub Actions)
├── Deploy to Cloudflare Workers
├── Cross-post to Dev.to (with canonical_url)
├── Post to X (using existing OAuth 2.0 system)
└── Save IDs to frontmatter → auto-commit

The key insight: track what’s already published in frontmatter. This prevents duplicate posts and enables article updates on Dev.to.

Implementation

Frontmatter Schema

Each blog post has optional fields for tracking published state:

---
title: "My Post"
description: "Post description"
pubDate: 2025-01-01
tags: ["tag1", "tag2"]
draft: false
devtoId: 123456        # Auto-set after Dev.to publish
tweetId: "1234567890"  # Auto-set after posting to X
---

Dev.to Publishing Script

The script checks if devtoId exists to determine create vs. update:

// scripts/publish-devto.ts
import { readPost, updateFrontmatter, getCanonicalUrl } from './lib/frontmatter.js';
import { createArticle, updateArticle } from './lib/devto.js';

const post = readPost(slug);
const canonicalUrl = getCanonicalUrl(slug);

// Add footer linking to original
const footer = `

---

*Originally published at [shusukedev.com](${canonicalUrl})*`;

const article = {
  title: post.frontmatter.title,
  body_markdown: post.content + footer,
  published: true,
  tags: post.frontmatter.tags.slice(0, 4),
  canonical_url: canonicalUrl,
};

if (post.frontmatter.devtoId) {
  // Update existing
  await updateArticle(post.frontmatter.devtoId, article);
} else {
  // Create new
  const result = await createArticle(article);
  updateFrontmatter(slug, { devtoId: result.id });
}

X Integration

The script generates a scheduled post YAML file:

// scripts/tweet-post.ts
const postText = `New post: ${title}

${description}

${url}`;

const yamlContent = `platform: twitter
status: scheduled
scheduledFor: ${new Date().toISOString()}
content: |
${postText.split('\n').map(line => '  ' + line).join('\n')}
`;

writeFileSync(`posts/twitter/scheduled/${fileName}`, yamlContent);

The workflow then calls npm run post:publish to post.

GitHub Actions Workflow

name: Publish Blog Post

on:
  workflow_dispatch:
    inputs:
      slug:
        description: 'Article slug'
        required: true
      skip_twitter:
        type: boolean
        default: false
      skip_devto:
        type: boolean
        default: false

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4

      # Deploy blog
      - run: npm ci
        working-directory: shusukedev-blog
      - run: npm run build
        working-directory: shusukedev-blog
      - run: npx wrangler deploy
        working-directory: shusukedev-blog

      # Cross-post to Dev.to
      - run: npm run post:devto -- ${{ inputs.slug }}
        if: ${{ !inputs.skip_devto }}
        working-directory: shusukedev-blog

      # Post to X
      - run: npm run post:tweet -- ${{ inputs.slug }}
        if: ${{ !inputs.skip_twitter }}
        working-directory: shusukedev-blog
      - run: npm run post:publish ${{ steps.tweet.outputs.TWEET_FILE_PATH }}
        if: ${{ !inputs.skip_twitter }}

      # Commit ID updates
      - run: |
          git add .
          git commit -m "Publish: ${{ inputs.slug }}" || true
          git push

SEO Considerations

Cross-posting the same content can hurt SEO if not done correctly. Two safeguards:

  1. canonical_url - Tells search engines the original is on shusukedev.com
  2. Footer link - Visible to readers: “Originally published at shusukedev.com”

Dev.to respects canonical URLs and won’t compete with your original in search results.

Update Behavior

ServiceFirst RunSubsequent Runs
CloudflareDeployRe-deploy
Dev.toCreate (POST)Update (PUT)
XPostSkip (tweetId exists)

This means I can fix typos and the Dev.to version stays in sync, while X doesn’t spam followers.

Tips

Preventing accidental links on X: X automatically converts domain-like strings (e.g., Dev.to) into clickable links, which can look messy. My script inserts a zero-width space (U+200B) after the dot to prevent this: Dev.\u200Bto looks identical but won’t be linked.

Conclusion

With ~200 lines of TypeScript and a GitHub Actions workflow, I now publish everywhere with one click. The frontmatter tracking pattern could extend to other platforms like Hashnode or Medium.

The full implementation is in my blog’s repository. Feel free to adapt it for your own workflow.

Have thoughts on this article?

Share your feedback or questions by quote posting on X.

Send a comment
← Back to all posts