My blog has had “scheduled posts” for a while now. I set a pubDatetime in the future, and Astro’s build process filters it out until that date passes. Simple enough.
The problem: nothing actually triggers a rebuild when that date arrives. The post just sits there, technically publishable, until I push something to master. Which means I’m either pushing a dummy commit at noon or just forgetting about it entirely.
I wanted a cron job that runs daily, checks if there’s anything new to publish, and deploys only when needed. No unnecessary builds, no wasted CI minutes.
How scheduled posts work here
Every post has a pubDatetime in its frontmatter:
pubDatetime: 2026-02-08T09:00:00+07:00At build time, a filter function checks whether that datetime has passed (with a 15-minute grace period):
const postFilter = ({ data }: AnyPost) => { const isPublishTimePassed = Date.now() > new Date(data.pubDatetime).getTime() - SITE.scheduledPostMargin; return !data.draft && (import.meta.env.DEV || isPublishTimePassed);};So the post exists in the repo but won’t appear on the site until a build happens after its pubDatetime. The missing piece is triggering that build automatically.
The check script
I wrote a small script (scripts/check-scheduled-posts.ts) that scans all markdown files in src/data/blog, src/data/notes, and src/data/links. For each file, it parses the frontmatter, extracts pubDatetime and draft, and decides whether the post is “due” — meaning it’s publishable now but was created after the last successful deploy.
The “last deploy” part is the interesting bit. On the first run there’s no cached timestamp, so everything publishable counts as due. After a successful deploy, the workflow saves the current UTC time to a file and caches it with actions/cache/save. On subsequent runs, it restores that cache and only flags posts whose effective publish time falls after the last deploy.
const effectivePub = pub.getTime() - MARGIN_MS;const publishableNow = now.getTime() >= effectivePub;
if (!publishableNow) continue;
if (lastDeploy) { const isNewSinceLastDeploy = pub.getTime() > lastDeploy.getTime(); if (isNewSinceLastDeploy) { due.push(file); }} else { due.push(file);}This approach handles missed runs gracefully. If GitHub Actions skips a day (outages happen), the next run still picks up yesterday’s post because the window extends back to the last successful deploy, not just “today.”
The script outputs should_deploy=true or false as a GitHub Actions step output, along with the count and list of due files.
The workflow
The deploy workflow now has a schedule trigger at 0 5 * * * (5 AM UTC, which is noon Jakarta time). It runs two jobs:
- check_scheduled_posts — restores the cached timestamp, runs the check script, and outputs whether a deploy is needed.
- deploy — the actual build and deploy, gated by a conditional:
if: >- ${{ github.event_name != 'schedule' || needs.check_scheduled_posts.outputs.should_deploy == 'true' }}Push, manual dispatch, and workflow_run triggers always deploy. The scheduled trigger only deploys when the check says there’s something new.
After a successful deploy, it saves the timestamp:
- name: Save deploy timestamp run: date -u +"%Y-%m-%dT%H:%M:%SZ" > .last-deploy-timestamp
- name: Cache deploy timestamp uses: actions/cache/save@v4 with: path: .last-deploy-timestamp key: last-deploy-ts-${{ github.run_id }}Using github.run_id as part of the cache key ensures each deploy creates a new cache entry. The restore step uses restore-keys: last-deploy-ts- to grab the most recent one.
Testing it locally
Running the script without a timestamp file simulates a first deploy. It flags every publishable post:
$ bun run scripts/check-scheduled-posts.tsLast deploy: (none, first run)Posts due since last deploy: 368Writing a recent timestamp and running again shows it correctly narrows the window:
$ echo "2026-02-06T04:00:00Z" > .last-deploy-timestamp$ bun run scripts/check-scheduled-posts.tsLast deploy: 2026-02-06T04:00:00.000ZPosts due since last deploy: 1 - src/data/blog/the-lazy-sysadmins-guide-to-docker.mdAnd with a timestamp after all current posts:
$ echo "2026-02-07T04:00:00Z" > .last-deploy-timestamp$ bun run scripts/check-scheduled-posts.tsLast deploy: 2026-02-07T04:00:00.000ZPosts due since last deploy: 0No posts due, no deploy. Exactly what I wanted.
Why not just run the cron unconditionally?
I could skip the check entirely and just build every day at noon. The site is small, builds take under a minute, and Astro is fast. But I like the idea of my CI being quiet when there’s nothing to do. GitHub Actions has usage limits even for public repos, and I’d rather save those minutes for actual work.
Plus the check script doubles as a useful debugging tool. I can run it locally to see what’s scheduled and when.
The full setup
Two files make this work. Since my repo is private, here they are in full.
The check script
scripts/check-scheduled-posts.ts — no dependencies, just Bun running TypeScript directly. It parses frontmatter with a simple regex, which works fine because my frontmatter is consistent. If yours isn’t, you might want to pull in gray-matter or similar.
import { readdir, readFile, appendFile } from "node:fs/promises";import { join } from "node:path";
const TZ = "Asia/Jakarta";const MARGIN_MS = 15 * 60 * 1000;const TIMESTAMP_FILE = ".last-deploy-timestamp";
const ROOT_DIRS = ["src/data/blog", "src/data/notes", "src/data/links"];
interface Frontmatter { pubDatetime: string | null; draft: boolean;}
function formatYMDInTZ(date: Date, timeZone: string): string { return new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", }).format(date);}
function parseFrontmatter(raw: string): Frontmatter | null { if (!raw.startsWith("---")) return null;
const lines = raw.split(/\r?\n/); if (lines[0].trim() !== "---") return null;
let end = -1; for (let i = 1; i < lines.length; i++) { if (lines[i].trim() === "---") { end = i; break; } } if (end === -1) return null;
const fmLines = lines.slice(1, end);
const getScalar = (key: string): string | null => { const re = new RegExp(`^\\s*${key}\\s*:\\s*(.+?)\\s*$`); for (const line of fmLines) { const m = line.match(re); if (m) { let v = m[1].trim(); if ( (v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")) ) { v = v.slice(1, -1); } return v; } } return null; };
const pubDatetime = getScalar("pubDatetime"); const draftRaw = getScalar("draft"); const draft = draftRaw ? draftRaw.toLowerCase() === "true" : false;
return { pubDatetime, draft };}
async function listMarkdownFiles(dir: string): Promise<string[]> { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return []; } return entries .filter(e => e.isFile() && e.name.endsWith(".md")) .map(e => join(dir, e.name));}
async function getLastDeployTime(): Promise<Date | null> { try { const raw = await readFile(TIMESTAMP_FILE, "utf8"); const ts = new Date(raw.trim()); if (!Number.isNaN(ts.getTime())) return ts; } catch { // no cached timestamp } return null;}
async function setOutput(key: string, value: string): Promise<void> { const outPath = process.env.GITHUB_OUTPUT; const line = `${key}=${value}\n`; if (outPath) { await appendFile(outPath, line); return; } process.stdout.write(line);}
async function main() { const now = new Date(); const lastDeploy = await getLastDeployTime();
const allFiles = ( await Promise.all(ROOT_DIRS.map(d => listMarkdownFiles(d))) ).flat();
const due: string[] = [];
for (const file of allFiles) { const raw = await readFile(file, "utf8"); const fm = parseFrontmatter(raw); if (!fm || !fm.pubDatetime) continue; if (fm.draft) continue;
const pub = new Date(fm.pubDatetime); if (Number.isNaN(pub.getTime())) continue;
const effectivePub = pub.getTime() - MARGIN_MS; const publishableNow = now.getTime() >= effectivePub;
if (!publishableNow) continue;
if (lastDeploy) { const isNewSinceLastDeploy = pub.getTime() > lastDeploy.getTime(); if (isNewSinceLastDeploy) { due.push(file); } } else { due.push(file); } }
const shouldDeploy = due.length > 0;
await setOutput("should_deploy", shouldDeploy ? "true" : "false"); await setOutput("due_count", String(due.length)); await setOutput("due_files", due.join(","));
console.log( [ `Now (UTC): ${now.toISOString()}`, `Today (Asia/Jakarta): ${formatYMDInTZ(now, TZ)}`, `Last deploy: ${lastDeploy ? lastDeploy.toISOString() : "(none, first run)"}`, `Posts due since last deploy: ${due.length}`, ...(due.length ? due.map(f => ` - ${f}`) : []), ].join("\n") );}
main().catch(err => { console.error(err); process.exit(1);});The workflow
The relevant parts of .github/workflows/deploy.yml. Your deploy step will look different depending on your hosting setup — I use SSH/rsync to a VPS, but Cloudflare Pages, Vercel, or Netlify would work the same way with their respective deploy actions.
name: Deploy Astro Site
on: workflow_dispatch: push: branches: - master schedule: # 12:00 Asia/Jakarta (UTC+7) == 05:00 UTC - cron: "0 5 * * *"
concurrency: group: deploy-astro-site cancel-in-progress: false
jobs: check_scheduled_posts: name: Check scheduled posts runs-on: ubuntu-latest outputs: should_deploy: ${{ steps.check.outputs.should_deploy }} due_count: ${{ steps.check.outputs.due_count }} steps: - name: Checkout uses: actions/checkout@v4
- name: Setup Bun uses: oven-sh/setup-bun@v1 with: bun-version: latest
- name: Restore last deploy timestamp uses: actions/cache/restore@v4 with: path: .last-deploy-timestamp key: last-deploy-ts- restore-keys: | last-deploy-ts-
- id: check name: Check for posts due since last deploy run: bun run scripts/check-scheduled-posts.ts
deploy: name: Build and deploy runs-on: ubuntu-latest needs: [check_scheduled_posts] if: >- ${{ github.event_name != 'schedule' || needs.check_scheduled_posts.outputs.should_deploy == 'true' }} steps: - name: Checkout uses: actions/checkout@v4
- name: Setup Bun uses: oven-sh/setup-bun@v1 with: bun-version: latest
- name: Install Dependencies run: bun install
- name: Build Astro Site run: bun run build
# Replace this with your own deploy step - name: Deploy to Server run: echo "Deploy your site here"
- name: Save deploy timestamp run: date -u +"%Y-%m-%dT%H:%M:%SZ" > .last-deploy-timestamp
- name: Cache deploy timestamp uses: actions/cache/save@v4 with: path: .last-deploy-timestamp key: last-deploy-ts-${{ github.run_id }}Now I can write a post, set the date, and forget about it. The blog takes care of the rest at noon.