Zero-CMS Blog Pipeline: SEO, Web Stories & Telegram
· Builds
A zero-CMS automated blog pipeline using GitHub Actions, AMP Web Stories, and Telegram — with actual code patterns for hands-on replication.
Last updated: July 2026 · 14-minute read
I write blog posts at midnight. By 7:30 AM the next morning, the post is live on my site, an AMP Web Story has been generated from it, the sitemap and RSS feed are updated, Google and Bing have been pinged, and a Telegram notification hits my phone with the direct link.
I don't press a single button. There is no CMS. There is no dashboard. There is no "publish" button. The entire pipeline runs on a free GitHub Actions cron job — one YAML file, four Node scripts, and zero external services beyond Telegram.
Here's how every piece works, with the actual code patterns that make it run.
The Architecture (30-Second Version)
The pipeline has six stages, each triggered by the previous one completing:
1. GitHub Actions cron fires daily at 7:30 AM IST 2. publish-scheduled.mjs scans Blog.tsx for posts whose publishDate has arrived, flips draft: false to false 3. generate-web-stories.mjs reads every published post, generates a 5-8 page AMP Story HTML file with structured data 4. generate-seo.mjs rebuilds sitemap.xml (with Google Discover image extensions) and feed.xml (with Media RSS) 5. notify-telegram.mjs sends an HTML-formatted message to my Telegram with the blog URL and Web Story URL 6. ping-search-engines.mjs notifies Google and Bing via standard ping + API indexing
All of this runs in a single GitHub Actions workflow. Total runtime: under 45 seconds. Total cost: ₹0.
The Trigger: GitHub Actions Cron
The entire pipeline starts with a single cron expression:
I also added workflowdispatch so I can manually trigger it from the GitHub UI if I want to publish immediately. The forcepublish input lets me bypass date checks — useful when I've written three posts in a night and want them all live before the next cron run.
The key design decision: the cron runs the scripts directly against the repo files. No build step, no deployment preview, no staging environment. It reads Blog.tsx, modifies it, commits, and pushes. Vercel picks up the push and deploys. The whole thing is simpler than most people's .gitignore.
Stage 1: The Publisher (The Only Script That Mutates Code)
The publish script does one thing: find posts where draft: false and publishDate <= now, then flip the draft flag.
The core pattern is a regex that matches a draft block and extracts the publish date:
For each match, it checks if the date has arrived. If yes, it does a string replacement — draft: false becomes draft: false — and writes the modified Blog.tsx back to disk.
But here's the part that took me three iterations to get right: deduplication. The first version had none. The second version only checked the draft flag. The third version — the one running now — has three layers:
Layer 1: Draft flag. Only targets draft: false posts. Already-published posts are draft: false, so they're invisible to the regex.
Layer 2: Sitemap check. Before publishing anything, the script loads public/sitemap.xml and extracts every /blog/ URL into a Set. If the slug is already in the sitemap, it skips — even if someone accidentally set draft: false on a live post.
Layer 3: Publish history log. A .published-log.json file in the repo root tracks every slug that has ever been published, with the timestamp. This persists across workflow runs because it's committed to the repo.
The script writes newly published slugs to /tmp/publishedslugs.txt (pipe-delimited: slug|title) so downstream steps know what to notify about.
Stage 2: Web Story Generator (AMP Format, Zero Templates)
This is the script I'm most proud of, because it solves a problem most people solve with a CMS and a plugin: generating Google Discover-eligible Web Stories from blog posts without a CMS.
Every published post gets a standalone AMP Story HTML file at public/stories/{slug}.html. Each story has 5-8 pages:
- Page 1 (Cover): The post's dedicated 768×1344 portrait cover image with a gradient overlay, category badge, title, and author line
- Page 2 (Hook): The post's excerpt displayed as a large pull-quote over the blurred cover
- Pages 3-N (Insights): Each ## heading from the blog post becomes a story page, with the first paragraph as body text
- Final Page (CTA): "Read the full article" with a link back to the blog post
The story generator extracts content using regex — it finds H2 headings and the paragraph that follows each one, then truncates to fit mobile screens:
Each story file includes full structured data (Article schema with ImageObject, author, publisher), Open Graph tags, and Twitter Card meta — all pointing to the dedicated 768×1344 cover image. This is what makes them eligible for Google Discover, which requires images larger than 1200px.
The script also generates a stories/index.json file that the React SPA reads to render the Stories Index page with actual cover thumbnails instead of gradient placeholders.
Stage 3: SEO Generator (Sitemap + RSS with Image Extensions)
The SEO script reads every published (non-draft) post from Blog.tsx and generates two files:
sitemap.xml — with Google's image sitemap extension. Every blog post URL includes an <image:image block pointing to the post's dedicated story cover. This is specifically for Google Discover, which uses the image sitemap extension to find visual content.
feed.xml — RSS 2.0 with Media RSS namespace. Each item includes a <media:content block with the cover image, which Google Discover also reads.
The final sitemap has 79 URLs — 40 blog posts, 36 Web Stories, and 3 static pages. Priority is set higher (0.9) for Tutorials and Insights posts, lower (0.8) for Builds and Experiments, and 0.85 for the Stories index.