skip to content
Khalid Abuhakmeh logo Khalid Abuhakmeh

GitHub Actions Cron: Skip Builds When No Posts Are Due

Read YAML front matter dates in a GitHub Actions cron and call your Netlify build hook only when a post is due. Works with Hugo, Jekyll, Astro, and 11ty.

8 min read
Gears inside of an antique clock. Photo by Aiden Rosewell on Unsplash.

Quick answer

Add a step before your build hook that reads the publish dates out of your posts' YAML front matter. Only call the hook when a non-draft post's date is yesterday or today. Checking takes a few seconds and skips most of the daily builds.

Back in 2020, I wrote about automating my blog with GitHub Actions, including a scheduled workflow that triggers a Netlify build hook every morning. Static sites only publish future-dated posts when they rebuild, so a daily build is the easy way to schedule posts ahead of time.

The problem is that the workflow has rebuilt this site every single day since then, and most of those builds changed nothing. I publish about once a week, so roughly six out of seven builds were wasted. In this post, I’ll add one step to the GitHub Actions schedule that reads the publish dates in my posts’ front matter and skips the build hook when no post is due.

Why not skip the build when nothing has changed?

Checking for new commits since the last run is the obvious idea, and it’s the wrong check here. Netlify already rebuilds on every push through its Git integration, so the scheduled build exists for the days when the repository hasn’t changed but a post’s date has arrived. Skipping when nothing has changed would skip exactly the builds the cron job is there for.

The question the cron job needs to answer is: did any post’s publish date arrive since the last run? You can answer that with the files in the repository and no API calls.

Which static site generators use YAML front matter dates?

Most popular static site generators keep a post’s publish date in YAML front matter, the --- fenced block at the top of a Markdown file. Those generators differ in whether they hide future-dated posts by default, and that decides whether a scheduled build is useful at all.

Generator Date field Hides future posts by default?
Hugo date or publishDate (YAML, TOML, or JSON) Yes, buildFuture defaults to false
Jekyll date, or the YYYY-MM-DD- filename prefix Yes, future defaults to false
Astro Whatever your content schema defines (pubDate in the blog template) No, you filter in your own code
Build Awesome (Eleventy/11ty) date No, you filter in your own collection

Eleventy fans: Eleventy is now Build Awesome, as of March 2026. The project, its front matter, and your existing build commands carry over, so everything here applies to both names.

This blog runs on Astro, and a post stays hidden until the build runs after its publishDate:

export async function getAllPosts() {
const now = Date.now();
return await getCollection("post", ({ data }) => {
if (!import.meta.env.PROD) return true;
if (data.draft) return false;
return data.publishDate.getTime() <= now;
});
}

If you use Docusaurus, MkDocs, or Gatsby with a date in YAML front matter, the same approach works as long as your build hides future dates.

The original scheduled workflow

Here’s the workflow I started with. It sends a POST to a Netlify build hook every day at 12:00 UTC, whether or not anything is due.

name: Netlify Deploy
on:
workflow_dispatch:
schedule:
- cron: "0 12 * * *"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Trigger Netlify Hook
run: curl -X POST ${{ secrets.netlify_build_url }}

How to skip the build when no posts are due

Add two steps before the hook: a sparse checkout of your content folder and a shell check of the front matter dates. Then put an if: condition on the hook step.

name: Netlify Deploy
on:
workflow_dispatch:
schedule:
# GitHub cron is always UTC. Avoid minute 0, the busiest time.
- cron: "17 12 * * *"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
if: github.event_name == 'schedule'
with:
sparse-checkout: content/posts
- name: Any posts going live?
id: due
if: github.event_name == 'schedule'
run: |
since=$(date -u -d 'yesterday' +%Y-%m-%d)
today=$(date -u +%Y-%m-%d)
due=$(find content/posts -type f \( -name '*.md' -o -name '*.mdx' \) -exec awk -v s="$since" -v t="$today" '
FNR == 1 { fm = ($0 ~ /^---[ \t]*$/) ? 1 : -1; date = ""; draft = 0; next }
fm == 1 && /^---[ \t]*$/ { fm = 2; if (!draft && date >= s && date <= t) print FILENAME; next }
fm == 1 && /^publishDate:/ { if (match($0, /[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/)) date = substr($0, RSTART, RLENGTH) }
fm == 1 && /^draft:[ \t]*true/ { draft = 1 }
' {} +)
if [ -n "$due" ]; then
echo "Posts going live:"; echo "$due"
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "No posts due between $since and $today; skipping build."
fi
- name: Trigger Netlify Hook
if: github.event_name != 'schedule' || steps.due.outputs.run == 'true'
run: curl -X POST ${{ secrets.netlify_build_url }}

Here’s what each part does:

  1. Sparse checkout pulls only content/posts, so the checkout is fast even in a repository full of images.
  2. find hands every Markdown and MDX file to a single awk script.
  3. awk reads only the front matter block, the lines between the opening and closing ---. A publishDate: line inside a code sample in the body is ignored, and so is a file that doesn’t start with ---.
  4. Drafts are skipped. A post with draft: true never triggers a build, because the site wouldn’t show it anyway.
  5. The date comparison works on plain YYYY-MM-DD strings, which sort in date order. Quoted dates work too. The check assumes date-only values, which Astro and most generators treat as midnight UTC. If you use full timestamps, awk keeps only the date part. A post timed after your cron’s time of day misses that day’s run, goes live on the next day’s run, and has no backup run if that one is dropped.
  6. $GITHUB_OUTPUT passes run=true to the hook step, and the hook step’s if: condition uses it. The log lists the posts that triggered the build.
  7. Manual runs through workflow_dispatch skip the check and always build, so you can still force a deploy.

The script sticks to POSIX awk features, so it behaves the same with mawk on GitHub’s Ubuntu runners as it does with the BSD awk on a Mac. On my site, the check finishes in seconds, and the hook fires only on the day a post goes live and the day after.

Why check yesterday as well as today?

The window covers yesterday and today because GitHub doesn’t guarantee that scheduled runs happen. GitHub’s documentation says the schedule event can be delayed under high load, and that some queued jobs may be dropped. With a two-day window, a post still goes live if one daily run goes missing, as long as the next day’s run happens. The trade-off is that each post triggers two builds, which costs far less than seven builds a week.

The window doesn’t protect you from a failed build. If Netlify fails on both days, the post stays hidden until you fix the build and trigger a deploy by hand, which is what the workflow_dispatch button is for.

Two other scheduling details are worth knowing:

  • Avoid the top of the hour. GitHub says the start of every hour is the busiest time, which is why the updated workflow runs at "17 12 * * *" instead of "0 12 * * *".
  • Watch for the 60-day shutoff. In a public repository, GitHub disables scheduled workflows after 60 days with no repository activity. If you queue posts months ahead and stop committing, check the Actions tab.

How to adapt the check for Hugo, Jekyll, and Build Awesome (11ty)

Only the patterns inside the awk script depend on your generator. The window and the hook step stay the same.

Build Awesome (Eleventy) and Astro use YAML front matter with whatever date field your templates read. Swap publishDate for date or pubDate, and point find and the sparse checkout at your content folder.

Hugo accepts YAML front matter between --- lines and TOML front matter between +++ lines, with key: value or key = value. Widen the three patterns to match both:

FNR == 1 { fm = ($0 ~ /^(---|\+\+\+)[ \t]*$/) ? 1 : -1; date = ""; draft = 0; next }
fm == 1 && /^(---|\+\+\+)[ \t]*$/ { fm = 2; if (!draft && date >= s && date <= t) print FILENAME; next }
fm == 1 && /^(date|publishDate)[ \t]*[:=]/ { if (match($0, /[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/)) date = substr($0, RSTART, RLENGTH) }
fm == 1 && /^draft[ \t]*[:=][ \t]*true/ { draft = 1 }

If your Hugo posts set both date and publishDate, match only publishDate, because that’s the field Hugo uses to decide whether a post is in the future. Hugo dates often include a time and offset, and this check ignores both. If you schedule posts for a specific time of day, widen the window to three days (date -u -d '2 days ago') so a dropped run still has a backup.

Jekyll posts often have no date field at all, because Jekyll reads the date from the filename. Drafts live in a separate _drafts folder, so you only need the filenames in _posts:

Terminal window
ls _posts | grep -oE '^[0-9]{4}-[0-9]{2}-[0-9]{2}' \
| awk -v s="$since" -v t="$today" '$0 >= s && $0 <= t {f=1} END {exit !f}'

Does this work with Vercel and Cloudflare Pages deploy hooks?

Yes. The check doesn’t care which host gets called. A build hook is a webhook URL that starts a deployment when something sends it an HTTP request, so you only change the URL in the last step:

Host Feature Request
Netlify Build hooks POST
Vercel Deploy hooks GET or POST
Cloudflare Pages Deploy hooks POST

Keep the hook URL in a repository secret, as the workflow does with secrets.netlify_build_url. Anyone with the URL can trigger a deploy of your site.

If you’d rather parse front matter with a real YAML parser than with awk, I wrote about parsing Markdown front matter with C#, and the same idea works as a small script step. For most blogs, a few lines of awk are enough to stop rebuilding a site that hasn’t changed.

Comments

Related Posts