DEV Community

Staff Systems Lab
Staff Systems Lab

Posted on

I Built a GitHub Action That Writes and Publishes a DEV.to Post Every Morning

I Built a GitHub Action That Writes and Publishes a DEV.to Post Every Morning

I wanted to answer a simple question:

Can I build a technical blog that keeps publishing without me manually writing and publishing every post?

So I built it.

Every morning, a GitHub Actions workflow wakes up.

It checks what engineers are currently talking about on DEV.to.

It gives those signals to an LLM.

The LLM proposes an original engineering angle and writes the article.

The pipeline validates the result.

Then it publishes the article directly to DEV.to.

Finally, it records what was published so tomorrow's job doesn't write the same thing again.

No laptop.

No browser.

No copy/paste.

No "I'll publish it later."

Just a scheduled engineering pipeline.

And the interesting lesson wasn't actually the AI.

It was everything I had to build around the AI.


The Architecture

The whole system looks like this:

                 ┌─────────────────────┐
                 │   GitHub Actions    │
                 │    Daily Schedule   │
                 └──────────┬──────────┘
                            │
                            ▼
                 ┌─────────────────────┐
                 │     DEV.to API      │
                 │   Rising Articles   │
                 └──────────┬──────────┘
                            │
                            ▼
                 ┌─────────────────────┐
                 │   Topic Selection   │
                 │                     │
                 │ Trends + History    │
                 └──────────┬──────────┘
                            │
                            ▼
                 ┌─────────────────────┐
                 │     Gemini API      │
                 │                     │
                 │ Generate Article    │
                 └──────────┬──────────┘
                            │
                            ▼
                 ┌─────────────────────┐
                 │    Validation       │
                 │                     │
                 │ JSON / length /     │
                 │ tags / duplicates   │
                 └──────────┬──────────┘
                            │
                            ▼
                 ┌─────────────────────┐
                 │     DEV.to API      │
                 │      PUBLISH        │
                 └──────────┬──────────┘
                            │
                            ▼
                 ┌─────────────────────┐
                 │   Topic History     │
                 │      Git commit     │
                 └─────────────────────┘
Enter fullscreen mode Exit fullscreen mode

It's basically a tiny content platform.

Except the content platform is a Git repository and a scheduled CI job.


Why GitHub Actions?

My first thought was to build a small backend.

Something like:

API server
  ↓
Database
  ↓
Scheduler
  ↓
LLM
  ↓
DEV.to
Enter fullscreen mode Exit fullscreen mode

But why?

The job runs once a day.

It doesn't need a server running 24/7.

It doesn't need a database cluster.

It doesn't need Kubernetes.

GitHub Actions already gives me:

  • scheduled execution
  • manual execution
  • logs
  • secrets
  • a Python runtime
  • Git access
  • failure visibility

So the infrastructure becomes almost zero.

That's one of those cases where boring infrastructure wins.


The GitHub Actions Workflow

The workflow is intentionally simple.

name: Daily DEV.to Article

on:
  schedule:
    - cron: "30 2 * * *"

  workflow_dispatch:

permissions:
  contents: write

jobs:
  publish:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: |
          pip install requests google-genai

      - name: Generate and publish
        env:
          DEVTO_API_KEY: ${{ secrets.DEVTO_API_KEY }}
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
        run: |
          python scripts/generate_and_publish.py

      - name: Commit topic history
        run: |
          git config user.name "devto-bot"
          git config user.email "devto-bot@users.noreply.github.com"

          git add data/topic_history.json

          git diff --cached --quiet || \
            git commit -m "chore: update DEV.to topic history"

          git push
Enter fullscreen mode Exit fullscreen mode

The cron expression:

30 2 * * *
Enter fullscreen mode Exit fullscreen mode

runs at 02:30 UTC, which is 08:00 IST.

And because workflow_dispatch is also configured, I can run the exact same workflow manually while developing it.


Step 1: Find What Engineers Are Talking About

I didn't want to maintain a spreadsheet containing:

Monday → React
Tuesday → System Design
Wednesday → TypeScript
Thursday → AI
Enter fullscreen mode Exit fullscreen mode

That would become stale very quickly.

Instead, the pipeline looks at DEV.to's rising content.

response = requests.get(
    "https://dev.to/api/articles",
    params={
        "state": "rising",
        "per_page": 17
    },
    headers={
        "api-key": DEVTO_API_KEY
    },
    timeout=30
)

response.raise_for_status()

articles = response.json()
Enter fullscreen mode Exit fullscreen mode

Now the pipeline has current signals.

Things like:

  • titles
  • descriptions
  • tags
  • reactions
  • comments
  • publication time

But there's an important distinction.

Trending does not mean copy this.

Trending means:

"This is something developers currently care about."

That's all.


Step 2: Turn a Trend Into an Engineering Angle

Suppose AI coding agents are trending.

A weak automation might produce:

"What Are AI Coding Agents?"

That's generic.

A better pipeline asks:

What engineering problem exists underneath this trend?

That could become:

"Why AI Coding Agents Need Architectural Guardrails"

Or:

"Your AI Coding Agent Doesn't Need More Autonomy. It Needs Better Boundaries."

Now we're no longer simply following a trend.

We're using the trend as a signal for finding a problem worth explaining.

That's the difference between:

AI → generate article
Enter fullscreen mode Exit fullscreen mode

and:

Trend
  ↓
Problem
  ↓
Engineering perspective
  ↓
Original article
Enter fullscreen mode Exit fullscreen mode

The second one is what I wanted.


Step 3: Give the Model Memory

There's another problem.

Imagine the automation runs for 30 days.

Without memory, it could eventually produce:

Day 1:
React Rendering Explained

Day 12:
Understanding React Rendering

Day 27:
How React Rendering Works
Enter fullscreen mode Exit fullscreen mode

Technically different titles.

Practically the same article.

So I created:

data/topic_history.json
Enter fullscreen mode Exit fullscreen mode

It starts as:

[]
Enter fullscreen mode Exit fullscreen mode

After publishing, it becomes something like:

[
  {
    "title": "Why AI Coding Agents Need Architectural Guardrails",
    "tags": [
      "ai",
      "architecture",
      "softwareengineering"
    ],
    "url": "https://dev.to/example/article"
  }
]
Enter fullscreen mode Exit fullscreen mode

The next generation receives recent history.

Now the model knows:

"We've already covered this."

It's not perfect memory.

But for a daily publishing system, it's surprisingly useful.


Step 4: Force Structured Output

This is where many LLM automations become fragile.

You ask:

Write an article.
Enter fullscreen mode Exit fullscreen mode

And get:

Sure! Here's your article...
Enter fullscreen mode Exit fullscreen mode

Then maybe some Markdown.

Then a conclusion.

Then:

Hope you enjoyed it!
Enter fullscreen mode Exit fullscreen mode

Good for a chat.

Terrible for an API pipeline.

So I define a contract.

{
  "title": "...",
  "description": "...",
  "tags": [
    "...",
    "..."
  ],
  "body_markdown": "..."
}
Enter fullscreen mode Exit fullscreen mode

The model is instructed to return only valid JSON.

Now the pipeline can treat the model like an unreliable external service.

And that's an important mindset shift:

Don't trust an LLM response just because it looks good.

Parse it.

Validate it.

Reject it if necessary.


Step 5: Validate Before Publishing

The model can generate something syntactically valid but operationally bad.

For example:

{
  "title": "",
  "description": "",
  "tags": [],
  "body_markdown": "..."
}
Enter fullscreen mode Exit fullscreen mode

So the pipeline performs deterministic validation.

For example:

def validate_article(article):

    if not article.get("title"):
        raise ValueError("Missing title")

    if not article.get("description"):
        raise ValueError("Missing description")

    body = article.get("body_markdown", "")

    if len(body) < 1000:
        raise ValueError("Article is too short")

    tags = article.get("tags", [])

    if not tags:
        raise ValueError("Missing tags")

    return True
Enter fullscreen mode Exit fullscreen mode

This is deliberately boring.

And that's exactly what I want.

AI decides what to write.

Code decides whether it is acceptable.


The Boundary I Care About

This became the central design principle of the project:

             AI
              │
              │ creative
              ▼
       ┌───────────────┐
       │ Article draft │
       └───────┬───────┘
               │
               │ deterministic
               ▼
       ┌───────────────┐
       │   Validation  │
       └───────┬───────┘
               │
               │ deterministic
               ▼
       ┌───────────────┐
       │    Publish    │
       └───────────────┘
Enter fullscreen mode Exit fullscreen mode

The LLM has freedom inside the creative boundary.

The system around it remains deterministic.

That's a much safer architecture than:

LLM
 ↓
Whatever it says
 ↓
Production
Enter fullscreen mode Exit fullscreen mode

Step 6: The Part I Didn't Expect — Gemini 503

During testing, the pipeline failed.

Not because my code was wrong.

Not because DEV.to rejected the API request.

The Gemini API returned:

503 UNAVAILABLE

This model is currently experiencing high demand.
Enter fullscreen mode Exit fullscreen mode

This was a useful reminder.

When you build an automated system, external APIs are dependencies, not guarantees.

A human running an AI tool might simply retry.

A GitHub Action can't.

So I added retries.

for attempt in range(3):

    try:
        return generate()

    except TemporaryError:

        delay = 10 * (2 ** attempt)

        time.sleep(delay)
Enter fullscreen mode Exit fullscreen mode

That gives:

Attempt 1
   ↓
10 seconds
   ↓
Attempt 2
   ↓
20 seconds
   ↓
Attempt 3
Enter fullscreen mode Exit fullscreen mode

And I also added a fallback model.

The important lesson isn't "Gemini sometimes returns 503."

It's:

Every external service in an automation needs a failure strategy.


Step 7: Publish Directly to DEV.to

Once the article passes validation, publishing is just an API request.

Conceptually:

payload = {
    "article": {
        "title": article["title"],
        "description": article["description"],
        "tags": article["tags"],
        "body_markdown": article["body_markdown"],
        "published": True
    }
}

response = requests.post(
    "https://dev.to/api/articles",
    headers={
        "api-key": DEVTO_API_KEY,
        "Content-Type": "application/json"
    },
    json=payload,
    timeout=30
)

response.raise_for_status()
Enter fullscreen mode Exit fullscreen mode

And that's the moment the pipeline changes from:

AI-generated draft
Enter fullscreen mode Exit fullscreen mode

to:

published article
Enter fullscreen mode Exit fullscreen mode

No human click required.


Secrets Stay Out of Git

There are two credentials the workflow needs:

DEVTO_API_KEY
GEMINI_API_KEY
Enter fullscreen mode Exit fullscreen mode

They are stored as GitHub Actions secrets.

The repository contains:

scripts/
    generate_and_publish.py

data/
    topic_history.json

.github/
    workflows/
        daily-devto.yml
Enter fullscreen mode Exit fullscreen mode

But never:

DEVTO_API_KEY=...
GEMINI_API_KEY=...
Enter fullscreen mode Exit fullscreen mode

inside the repository.

This sounds obvious.

Until someone accidentally commits an API key at 2 AM and discovers it the next morning.


The Complete Pipeline

Putting everything together:

                  ┌──────────────────────┐
                  │    GitHub Scheduler  │
                  └──────────┬───────────┘
                             │
                             ▼
                  ┌──────────────────────┐
                  │   DEV.to Rising API  │
                  └──────────┬───────────┘
                             │
                             ▼
                  ┌──────────────────────┐
                  │  Topic Selection     │
                  │                      │
                  │ trends + history     │
                  └──────────┬───────────┘
                             │
                             ▼
                  ┌──────────────────────┐
                  │     Gemini API       │
                  │                      │
                  │  Generate article    │
                  └──────────┬───────────┘
                             │
                             ▼
                  ┌──────────────────────┐
                  │     JSON Parse       │
                  └──────────┬───────────┘
                             │
                             ▼
                  ┌──────────────────────┐
                  │      Validate        │
                  │                      │
                  │ title                │
                  │ description          │
                  │ tags                 │
                  │ length               │
                  │ duplicates           │
                  └──────────┬───────────┘
                             │
                       PASS  │
                             ▼
                  ┌──────────────────────┐
                  │    DEV.to Publish    │
                  └──────────┬───────────┘
                             │
                             ▼
                  ┌──────────────────────┐
                  │  Save Topic History  │
                  └──────────────────────┘
Enter fullscreen mode Exit fullscreen mode

It's only a few hundred lines of Python.

But the architecture is more interesting than the amount of code.


What I Would Build Next

The first version works.

But it could become much smarter.

1. Multiple Trend Sources

Instead of relying only on DEV.to:

DEV.to
Hacker News
Reddit
GitHub Trending
Google Trends
Enter fullscreen mode Exit fullscreen mode

Then combine the signals.


2. Topic Scoring

Instead of:

pick something trending
Enter fullscreen mode Exit fullscreen mode

score topics based on:

trend strength
+ relevance to my expertise
+ novelty
+ historical performance
Enter fullscreen mode Exit fullscreen mode

Something like:

Topic Score =
    30% trend
  + 25% relevance
  + 20% novelty
  + 15% historical performance
  + 10% freshness
Enter fullscreen mode Exit fullscreen mode

Now topic selection becomes an actual ranking problem.


3. Research Before Writing

The current pipeline mainly uses metadata.

A stronger version could:

Trend
 ↓
Research
 ↓
Primary sources
 ↓
Technical verification
 ↓
Article
Enter fullscreen mode Exit fullscreen mode

That would make the generated posts substantially more useful.


4. A Quality Gate

Before publishing, another model could review the article.

For example:

Writer
  ↓
Reviewer
  ↓
Score
  ↓
Publish only if score >= 8
Enter fullscreen mode Exit fullscreen mode

The reviewer could check:

  • technical correctness
  • originality
  • clarity
  • usefulness
  • code quality
  • clickbait
  • unsupported claims

This introduces an interesting pattern:

One model creates. Another model critiques. Code makes the final decision.


5. Close the Analytics Loop

Eventually the system could learn from its own results.

Article
   ↓
Publish
   ↓
Views
Reactions
Comments
   ↓
Analytics
   ↓
Topic scoring
   ↓
Better next article
Enter fullscreen mode Exit fullscreen mode

At that point, the system isn't merely generating content.

It's becoming a feedback loop.


The Bigger Lesson

I started this project thinking:

"How can I automate writing a DEV.to article?"

But that's actually the easy part.

The hard part is everything around the generation:

  • What should we write?
  • How do we avoid repeating ourselves?
  • How do we structure model output?
  • What happens when the API fails?
  • How do we validate generated content?
  • How do we protect credentials?
  • When should we publish?
  • How do we know the article is good?
  • How do we learn from previous results?

That's the real engineering problem.

And it leads to a pattern I'm increasingly interested in:

              AI
               │
       Generate / Reason
               │
               ▼
       ┌──────────────┐
       │ Deterministic│
       │    System    │
       └──────────────┘
               │
        Validate / Act
               │
               ▼
          External World
Enter fullscreen mode Exit fullscreen mode

AI is powerful at the fuzzy parts.

Traditional software is still much better at enforcing contracts.

The strongest systems combine both.


Final Thought

I don't think the interesting future is:

"AI will write all the code."

I think it's closer to:

Software systems will increasingly contain AI components, while the surrounding architecture becomes responsible for constraining, validating, observing, and recovering from them.

This little DEV.to bot is a toy example of that idea.

The LLM writes.

The pipeline decides.

Git remembers.

DEV.to publishes.

And tomorrow morning, the whole thing starts again.


Repository Structure

The implementation is roughly:

staff-engineer-academy/
│
├── .github/
│   └── workflows/
│       └── daily-devto.yml
│
├── scripts/
│   └── generate_and_publish.py
│
└── data/
    └── topic_history.json
Enter fullscreen mode Exit fullscreen mode

If you're building your own AI automation, I'd strongly recommend starting with this principle:

Treat the LLM as a powerful but unreliable dependency — not as the system itself.

Top comments (0)