Skip to content
← Back to articles
8 min read

Zero-Touch AI Publishing Pipelines

A deep dive into architecting an automated, Git-backed content pipeline using GitHub Actions, TypeScript, and the Gemini API for continuous publishing.

Zero-Touch AI Publishing Pipelines
In this post

TL;DR: Manual content creation doesn’t scale for solo developers. By combining GitHub Actions cron jobs, Git commit history, and the Gemini API via TypeScript scripts, you can build a resilient, fully autonomous content pipeline that researches, drafts, and opens PRs without human intervention.


The limiting factor for most independent developers isn’t shipping code—it’s discoverability.

We know we need to write to attract an audience and index on technical search engines, but the friction of context-switching from an IDE to a text editor is often enough to kill the habit.

The solution isn’t to write more; the solution is to write less. It’s to treat content creation as an engineering problem and automate it.

This post details the architecture of a “zero-touch” publishing pipeline: a system that leverages your natural workflow (committing code) to automatically generate high-quality, technical dev logs and articles via AI.

Table of Contents

The Architecture of Automation

The core philosophy of this pipeline is that your Git history is your context index.

Every commit, PR, and closed issue tells the story of what you’re building.

Instead of writing a blog post from scratch, we use automated scripts to fetch this history and pass it to an LLM.

The AI acts as a technical writer, synthesizing the raw activity into a structured, SEO-optimized narrative.

graph TD
    A[GitHub Actions Cron Job] -->|Triggers Weekly| B(TypeScript Script)
    B --> C{Fetch Context}
    C -->|git log| D[Recent Commits]
    C -->|gh api| E[Recent PRs & Issues]
    D --> F[Build Prompt Template]
    E --> F
    F --> G[Gemini API]
    G -->|Returns MDX| H[Save to src/data/blog/]
    H --> I[Create Pull Request]
    I --> J{Human Review}
    J -->|Merge| K[Deploy to Vercel]

Phase 1: The Execution Engine (GitHub Actions)

The heartbeat of the system is a GitHub Actions workflow triggered by a cron schedule.

This ensures the pipeline runs consistently, regardless of whether you’re at your computer.

# .github/workflows/generate-blog.yml
name: Scheduled Content Generation
on:
  schedule:
    - cron: '0 16 * * 5' # Run every Friday at 4 PM UTC
  workflow_dispatch: # Allow manual triggers for testing

jobs:
  generate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 50 # Ensure enough git history is available

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'

      - name: Execute Content Pipeline
        run: npm run generate:blog
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}

      - name: Create Pull Request
        uses: peter-evans/create-pull-request@v6
        with:
          title: '🤖 Auto-Generated Content Draft'
          branch: 'auto-content-draft'
          commit-message: 'feat(content): add AI generated draft'

The critical piece here is peter-evans/create-pull-request. The pipeline should never commit directly to the main branch.

Automated content must always go through a human review gate to maintain quality and voice.

Phase 2: Context Gathering and Prompt Engineering

The TypeScript script executed by the pipeline (scripts/generate-blog.ts) is responsible for fetching context and building the prompt.

Because we are using ESM ("type": "module" in package.json), we can leverage modern async/await patterns and top-level await.

First, we gather the raw data. The child_process module allows us to interact directly with the Git tree.

// scripts/generate-blog.ts
import { execSync } from 'child_process';

function getRecentCommits(days: number = 7): string {
  try {
    // Note: Always use explicit array arguments with spawn in production,
    // but execSync is acceptable for simple local git commands without user input.
    const since = new Date();
    since.setDate(since.getDate() - days);
    const dateStr = since.toISOString().split('T')[0];

    return execSync(`git log --since="${dateStr}" --oneline --no-merges`).toString();
  } catch (error) {
    console.warn('Failed to fetch git history:', error);
    return 'No recent commits found.';
  }
}

This raw log is then injected into a heavily engineered prompt template. The prompt must dictate not just the topic, but the structure, tone, and constraints (e.g., Markdown schema).

const systemPrompt = `
You are a senior software architect writing a technical blog post.
Your tone is authoritative, minimalist, and pragmatic.
Analyze the following git commit history to determine what the developer has been building.

Git History:
${getRecentCommits()}

Constraints:
1. Output valid MDX.
2. Include YAML frontmatter with 'title', 'description', 'pubDate', 'category', 'tags'.
3. Set 'draft: false' in frontmatter.
4. Begin the content with a '**TL;DR:**' block wrapped in <div aria-atomic="true">.
5. Escape literal HTML tags used in text with backslashes (e.g., \\<script\\>).
`;

Phase 3: AI Integration and File Generation

Finally, we dispatch the prompt to the LLM (in this case, the Gemini API via the official Google AI SDK) and write the resulting output to the correct directory.

import { GoogleGenerativeAI } from '@google/generative-ai';
import { writeFileSync } from 'fs';
import { join } from 'path';

async function generateContent() {
  const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
  const model = genAI.getGenerativeModel({ model: 'gemini-1.5-pro' });

  const result = await model.generateContent(systemPrompt);
  const mdxContent = result.response.text();

  // Extract a slug from the generated frontmatter (simplified)
  const titleMatch = mdxContent.match(/title:\s*"([^"]+)"/);
  const slug = titleMatch
    ? titleMatch[1]
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/(^-|-$)/g, '')
    : `auto-draft-${Date.now()}`;

  const filepath = join(process.cwd(), `src/data/blog/${slug}.mdx`);

  writeFileSync(filepath, mdxContent, 'utf-8');
  console.log(`Successfully generated draft at ${filepath}`);
}

await generateContent();

Security and Operational Considerations

When architecting automation that runs autonomously, defensive engineering is mandatory.

  1. Command Injection: When using child_process.exec or execSync, ensure that no unsanitized inputs (like user-provided strings or branch names) are passed directly into the command string.

Always prefer spawn with argument arrays for complex commands. 2. MDX Parsing Failures: AI models often output literal <script> or <link> tags when discussing code.

In Astro, MDX treats these as JSX components, which will fail the build if not imported.

Your prompt must explicitly instruct the model to backslash-escape literal HTML tags (e.g., \<script\>) or wrap them in code blocks.

  1. JSON-LD Serialization: If your blog implementation injects schema.org data via inline script tags, never use raw JSON.stringify().

Use a utility like escapeJsonForScript to replace <, >, and & to prevent Cross-Site Scripting (XSS) vulnerabilities if the AI generates malicious or malformed strings.

The Payoff

This setup transforms the daunting task of “writing a blog post” into a routine code review.

The pipeline observes your work, drafts the narrative, and simply asks for your approval via a Pull Request.

By shifting the burden of drafting to AI, you can focus purely on engineering and editing.

You maintain the “solo developer” voice, ensure high technical fidelity, and keep your site fresh and indexed—all without breaking your development flow.

Written by Jordan Thirkle

Stay-at-home dad building AI-accelerated products. I write code during naps and after bedtime — every post comes from real work, not theory.

X GITHUB LINKEDIN NEWSLETTER
0