TL;DR
- A 2-day build: `publishedAt` field on every doc, Astro filter, 30-min GitHub Actions cron, Resend digest 2× daily, instant live-notification per post, Kanban dashboard. Runs on free tiers. $0/mo added.
- The job served: Job 9 (content-ops at scale) plus leverage Job 7 (automate ops). Drip publishing lets a new domain grow content organically without Google's helpful-content classifier flagging a content storm.
- The catch: Drip cadence does not fix bad content. The em-dash sweep, Posture A/B/C rules, and data-currency protocol live alongside the drip system because the system is plumbing. The writing is the moat.
The Problem: Google Punishes Content Storms on New Domains
Vibetoolstack hit the public web with 28 tool reviews, 6 stack guides, 2 listicles, and around 30 compare pages already deployed. That is a substantial corpus for a freshly-indexed domain. Google's Helpful Content System looks at growth patterns; a site that goes from 0 to 70 pages in a week is doing something humans rarely do organically. The classifier registers a content storm.
Then I made it worse. Every fix I shipped (the em-dash sweep, the affiliate-status sanitization, the Posture A/B/C rendering, the brand-pivot reframing) triggered a full Astro rebuild and a wrangler deploy. Every deploy updates the lastModified timestamp on every page. In a single afternoon I rotated lastModified on all 70 pages 7 times. That looks, statistically, like a site that is being shaken by an algorithm. Google sees it. Crawl budget gets reallocated. Trust signals soften.
Two structural fixes, both addressed by drip-publishing:
- New content goes live on a scheduled cadence (max 2 to 3 docs per day, not 20).
- Updates to existing docs happen on their own clock, not bunched into a single deploy-storm.
This post walks through how I built the system. Real code, real numbers, current as of 2026-05-12. If you want to ship the same setup on your own Astro + Sanity + Cloudflare stack, the code is enough to copy directly.
The Architecture (in One Diagram)
The pieces, in the order a request flows:
- Sanity production holds every doc. Each tool review, blog post, stack guide, switch guide, glossary entry, and tool sub-page has a publishedAt datetime field. NULL means legacy, always-live. A set value means scheduled.
- Astro queries everywhere filter the doc set by publishedAt before rendering. Docs with a future publishedAt do not appear on the live site at all. getStaticPaths skips them; sitemap excludes them; internal references inside other docs are also filtered.
- GitHub Actions runs a deploy cron every 30 minutes between 06:00 and 22:30 UTC. The cron runs npm run build (Astro reads Sanity, builds the static dist/), then wrangler deploy pushes to Cloudflare Workers. Any doc whose publishedAt has just ticked past now() shows up in the next build.
- Cloudflare Workers serves the static dist/ via the assets binding. A small worker script wraps requests to /internal/* with HTTP Basic Auth so the pipeline dashboard is not public.
- Resend (via the GitHub Action) sends a daily digest twice a day plus an instant live-notification per newly-published doc.
The cron is the heartbeat. Everything else is just plumbing around it.
Step 1: Sanity Schema (publishedAt Field on Six Doctypes)
The drip-publish field lives on every doctype that can become a public URL. That meant adding the field to six existing schemas: tool, blogPost, stack, switchGuide, glossaryEntry, toolSubpage.
Each addition is the same pattern:
defineField({
name: 'publishedAt',
title: 'Published At (Drip-Publish Slot)',
type: 'datetime',
group: 'core',
description: 'Auto-drip: Astro filters publishedAt <= now() at build time. Set future date to schedule release. NULL = legacy, always-live.',
})blogPost already had publishedAt from the original schema scaffold (it was always used for sort order on /blog). The other five were new. After a sanity deploy, Sanity Studio shows the field on every relevant doc, and the cron-driven build query understands it.
One nuance: the field is datetime, not date. Drip slots can be hour-and-minute precise (publish at 07:30 UTC, not just "sometime today"). With a 30-minute cron, the smallest reliable slot granularity is 30 minutes, but the field stores the full datetime for clean reporting in the digest and dashboard.
Step 2: Backfill 37 Existing Docs
Schema is the easy part. The harder part: every doc that already exists needs a publishedAt value set, otherwise the next deploy makes all of them disappear from the live site (the Astro filter is, by design, exclusive).
Backfill script: scripts/backfill-publishedAt.mjs. Heuristic per doctype:
- tool: firstSeenDate (the date VTS first added the tool to its tracking) falls back to _createdAt.
- blogPost: existing publishedAt is preserved; otherwise updatedAt; otherwise _createdAt.
- stack, switchGuide, glossaryEntry, toolSubpage: _createdAt.
The script is idempotent: it only patches docs where publishedAt is currently null. Re-running on already-backfilled docs is a no-op. Ran it twice (once on development, once on production); 37 docs patched in each, 1 already set (the blogPost that had it from before).
Step 3: Astro Query Filter (29 GROQ Injections)
Astro queries Sanity in 13 separate files. Every public-facing query needs the publishedAt filter so future-dated docs do not leak. Manual edit count if done by hand: 29 separate query strings.
Instead of doing it by hand, I wrote a one-off injector: scripts/inject-publishedAt-filter.mjs. It walks src/, finds *[_type == "X"...] patterns for the six doctypes, and injects && (!defined(publishedAt) || dateTime(publishedAt) <= dateTime(now())) before the closing bracket. Idempotent: re-running detects the existing filter and skips.
One regex bug surfaced during the first run. The pattern *[_type == "stack" && ^._id in tools[]._ref] has nested brackets. My injector greedily matched the inner closing bracket and stuck the filter in the wrong place, producing tools[ && (filter)]._ref which is invalid GROQ. The build failed with a parse error: "expected ']' following expression". Three files affected, manually fixed in ~2 minutes. Lesson: be careful with simple regex on syntax with nested delimiters; better tools would parse the AST.
The reference-expansion subqueries (alternativesRefs, commonComparisons, integrations, sameCategoryPeers, subpages, switches, stacks) also got the filter. This is the part that solves the internal-linking concern: tool A links to tool B via alternativesRefs. If B has a future publishedAt, B does not appear in A's expanded reference list at all. When B's slot ticks past now() on a future deploy, A's page (rebuilt by the cron) suddenly shows B in alternatives. Zero broken links ever.
Step 4: GitHub Actions Cron (the Heartbeat)
The cron schedule lives in.github/workflows/deploy.yml. Three triggers:
- on: push to main (immediate deploy on code changes)
- on: schedule '0,30 6-22 * * *' (every 30 minutes, 06:00 to 22:30 UTC = 08:00 to 00:30 Berlin in CEST)
- on: schedule '30 3 * * *' and '30 15 * * *' (two daily digest slots, 05:30 and 17:30 Berlin)
The deploy job is straightforward: checkout, setup-node, npm ci, npm run build with Sanity env vars, then cloudflare/wrangler-action@v3 with wranglerVersion '4' (a critical pin, see the lessons section). A typical build is around 2 minutes. The action's concurrency: group config prevents pile-up if a push happens during a scheduled run.
Cost: I am on GitHub's free tier. The cron uses around 30 minutes of Actions runtime per day. That is 900 minutes per month. The free tier allows 2000 minutes for private repos. Plenty of headroom.
Step 5: Resend Email Notifications (Digest + Instant Live-Notif)
Two scripts, two purposes:
- scripts/send-daily-digest.mjs: runs on the two daily digest slots (05:30 and 17:30 Berlin). Queries Sanity for what went live in the last 24 hours, what is queued for the next 24 hours, what is queued for the next 7 days, plus a stale-pillar counter (tools whose lastTestedDate is older than 2026-05-11, the brand-pivot cutoff). Renders a clean HTML summary with a queue-depth indicator (green for 5+ days, yellow for 2-4, red for 0-1).
- scripts/send-live-notifications.mjs: runs on every 30-minute cron. Queries Sanity for docs whose publishedAt fell in the last 35 minutes. For each match, sends an immediate Resend email titled '🚀 LIVE: {Doc Name}' with the doc's URL, type, and one-line take. The 35-minute window covers the 30-minute cron interval plus 5 minutes of clock drift; in the rare case of a delayed cron, a doc could trigger a duplicate notification, which is acceptable for the MVP. If this becomes annoying, the next iteration tracks last-checked timestamp in a Sanity singleton doc.
Sender: vts@proptradingvibes.com (an existing verified Resend domain on Paul's account, used here as the from-address while vibetoolstack.com is queued for its own DNS verification). The display name 'Vibetoolstack Drip' is what shows in Gmail; the underlying domain only appears in headers. Future polish: verify vibetoolstack.com directly. Not blocking.
Step 6: The Internal Kanban Dashboard
Internal pipeline view at vibetoolstack.com/internal/pipeline. Four columns:
- ✍ Drafting: docs with no publishedAt set yet (in-progress).
- ⏰ Queued: docs with publishedAt in the future.
- 🟢 Live-Fresh: docs with publishedAt <= now() AND lastTestedDate >= 2026-05-11 (post-brand-pivot).
- 🟡 Live-Stale: live docs not yet reframed under the new positioning (need refresh pass).
Above the columns, a header banner shows queue depth in days (color-coded), total counts per column, and a build timestamp (the page is a static-built Astro route, refreshed on every 30-minute cron deploy).
The auth gate is a Cloudflare Worker that wraps every request. /internal/* paths require HTTP Basic Auth (browser-native password prompt). Wrong creds return 401 with WWW-Authenticate. Correct creds pass through to the static asset binding with no-cache headers. Everything outside /internal/* bypasses the worker logic entirely (negligible perf impact).
There was a bug worth flagging: by default, Cloudflare's static asset binding serves matched files directly, skipping the worker. /internal/randomtest (no matching file) triggered the worker correctly (401), but /internal/pipeline (a real built file) bypassed it (200). Fix: assets.run_worker_first: ["/internal/*"] in wrangler.jsonc forces the worker to run first on those paths. Public paths still benefit from the fast asset-first path.
The Math: What This System Actually Costs
$0 per month.
Breakdown:
- GitHub Actions: 30 min/day on free tier (2000 min/mo for private repos). Current usage: ~900 min/mo.
- Cloudflare Workers: well under the 100,000 requests/day free tier. The internal dashboard adds maybe 50 requests/day total.
- Resend: ~50 to 90 emails per month (2 digests/day + sporadic live-notifs). Free tier is 3000/month.
- Sanity: same plan as before (Free or Growth, depending on Studio user count). No additional document count or API hit cost.
If usage grew 100x, I would hit the GitHub Actions cap first, then Resend. Both have predictable paid tiers.
Where This Wins, Where It Loses
Where it wins:
- No manual deploys for content. I write in Sanity Studio, set publishedAt, walk away. The cron handles the rest.
- Drip cadence is configurable. Today: 2 to 3 docs/day. If a content sprint produces 20 articles in a week, I can spread their publishedAt across two weeks and Google sees gentle growth, not a storm.
- Internal linking stays clean. Future-dated docs are invisible everywhere on the live site, including reference expansions. When they go live, the next cron rebuild surfaces them in every back-link automatically.
- Pipeline visibility. The Kanban dashboard shows queue depth at a glance. The digest tells me when to produce more.
Where it loses:
- Cron drift. Cloudflare Workers cold starts and GitHub Actions queue delays can push a slot by 5 to 15 minutes. If you care about exact-minute publishing (e.g., synchronized with a Twitter post), this is too loose. Most blogs do not need that.
- No content workflow beyond publishedAt. There is no "draft → review → approve" gate. If a doc has publishedAt set and the date arrives, it goes live, full stop. Add a separate status field if you need editorial review.
- Drip does not save bad content. If a tool review is thin or fakes hands-on experience, drip-publishing it just spreads the problem across more days.
- CDN cache lag on initial Worker activation. The cache held a pre-auth response of /internal/pipeline for ~10 minutes after the worker rolled out. Manual purge fixed it. New deploys do not have this issue because no-cache headers are now in _headers.
Five Bugs I Hit and Fixed
Documenting the gnarly bits because someone else hitting the same wall will save an hour.
1. Wrangler v3 vs v4 mismatch in GitHub Actions
cloudflare/wrangler-action@v3 defaults to wrangler 3.90. My local wrangler is 4.x. The 3.x version errors on static-assets-only deploys: "Missing entry-point." Fix: with: wranglerVersion: '4' in the action step.
2. Astro queries needed the filter in 29 places, not 5
I initially thought a couple of top-level queries needed updating. Reference expansions (alternatives[]->{}, commonComparisons[]->{}, integrations[]->{}, sameCategoryPeers, subpages, switches, stacks) also resolve to docs whose publishedAt matters. The injector handled it cleanly once I realized the scope.
3. Em-dashes everywhere
Unrelated to drip but found during the same sweep. AI-assisted drafting had snuck em-dashes into 26 of 29 Sanity docs and 45 of 52 static template files. 288 em-dashes total. Banned in vts-ai-slop-filter.md §1, sanitized via two scripts (sanitize-em-dash.mjs for Sanity, sanitize-em-dash-files.mjs for templates), audit-em-dash.mjs added as a pre-deploy gate.
4. Affiliate-status statements
"Vibetoolstack is not currently in the X partner program" appeared in 19 pillar disclosures. Time-sensitive prose that becomes false the moment we join a program. Replaced with a canonical, time-neutral disclosure across all 19 docs via sanitize-affiliate-status-claims.mjs. Codified as E9 to E12 in vts-ai-slop-filter.md.
5. run_worker_first for /internal/*
Already covered above. Cloudflare's default is asset-first, worker-on-miss. Setting run_worker_first to a route array forces worker-first for those routes. Without it, the auth gate did not protect the actual /internal/pipeline page (which exists as a static asset).
Methodology
Posture A: Hands-on.This is a Vibetoolstack build log. Every step described actually ran. Every file mentioned exists in thevts-astro(private) andvts-sanity(private) repositories. The cron is running as you read this; check vibetoolstack.com/internal/pipeline (auth-gated) for the live state.
Dates: drip-publish system designed and built 2026-05-11. This post written and scheduled 2026-05-11 with publishedAt 2026-05-12T07:30:00Z. Tool versions current as of those dates: Astro 5.x, Sanity Studio v37ab5f80, Wrangler 4.90.0, GitHub Actions runner ubuntu-24.04 with Node 22.22.2.
Tools used in production:Sanity(headless CMS),Claude Code(the pair-coder that paired with me on every script), GitHub Actions (free tier), Cloudflare Workers (free tier), Resend (free tier).
Affiliate status: Vibetoolstack reviews tools we would recommend to readers building toward $10k/mo of independent income. Where an affiliate program exists and we participate, the link is marked. Where not, links are editorial. The verdict above does not depend on affiliate status.
FAQ
Why not Cloudflare Pages instead of Workers + static assets?
Cloudflare Pages is a fine choice and is what most Astro tutorials assume. I am on Workers because the asset binding plus a small worker script gives me path-scoped auth gates (the /internal/pipeline use case) without a separate auth service. Pages can do this via Functions, but the deployment story is simpler with Workers + assets binding.
Why 30 minutes between cron runs?
Cloudflare Workers cron and GitHub Actions cron both have minute-level granularity. 30 minutes is the smallest slot precision that does not feel wasteful. If I had 100 docs queued for a single day and wanted hourly drip cadence, I could increase frequency. For 2 to 3 docs per day, 30 minutes is overkill in a good way.
What about non-publish operations (e.g. fixing a typo)?
Fixing a typo on an existing live doc still triggers a wrangler deploy via push-to-main. That rotates the lastModified timestamp on every page (because Astro rebuilds everything). For content updates, I batch them: do the work in Sanity, wait for the cron to deploy. The 30-minute interval becomes the natural batching window.
Can I use this on Cloudflare Pages instead?
Yes. The Astro filter and Sanity schema work identically. Replace the GitHub Action's Workers deploy step with the Pages deploy action, and replace the worker.js auth wrapper with a Pages Function. Pages Functions support the same env binding pattern.
What happens if I miss a digest slot?
Nothing breaks. The digest script no-ops if no activity happened and queue is healthy. If you delete a queued doc, it just disappears from the queue. If a slot triggers and the deploy fails for a non-related reason, the next slot picks up where the previous left off (publishedAt is the source of truth).
Could I run the digest on Slack or Telegram instead?
Trivially. The script that calls Resend is 100 lines. Swap the fetch URL and request body for Slack incoming webhooks or Telegram Bot API. The data-fetching half (Sanity query, bucket-by-publishedAt) is identical.