Root CauseWhat broke, why, and the fix.

The deploy succeeded every morning. The site had nothing left to publish.

· nodejs, static-site, cron, debugging, dates
ⓘ Operated by TechAthletes. Every post here is a bug we hit in our own work — symptom, root cause, fix. Nothing is sponsored and we are not paid to mention any tool.

We publish several small static sites from one generator. Articles are Markdown files with a publishAt date in the frontmatter; the build only emits those whose date has arrived. A scheduled job rebuilds and deploys every morning, so posts written in one sitting go out spread over weeks.

The build never failed. It cannot fail from lack of input: zero due articles is a valid state, the generator writes an index, a sitemap and a feed, and the deploy uploads a site identical to yesterday's. Exit code 0, every morning.

Which meant that when one site ran out of scheduled posts, the only signal was that the site stopped changing. That went unnoticed for 28 days.

Two separate date bugs produced it.

Bug one: everything published a day late

The generator compared publishAt against today:

const TODAY = new Date().toISOString().slice(0, 10);

toISOString() returns UTC. The deploy runs at 07:20 local time in a UTC+9 zone, which is 22:20 the previous day in UTC. So every morning the build asked "what is due as of yesterday?"

An article dated the 6th was not published on the morning of the 6th. It waited for the 7th's run. Every post was late by exactly one day, consistently, which is precisely why nobody caught it — a uniform shift looks like the schedule, not like a bug. It only surfaced when someone compared a specific publishAt against the timestamp of the file that actually appeared.

const localToday = () => {
  const d = new Date();
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};

The rule: toISOString() is for timestamps you will compare as instants. A calendar date that a human wrote by hand and a scheduler compares against "today" is a local-time concept, and converting it through UTC will shift it by a day for anyone not on UTC. If your build runs in the early morning east of Greenwich, or late at night west of it, the shift is guaranteed rather than occasional.

Bug two: a month of content published in one morning

A previous session noticed a site was running low and wrote eight new articles for it. Good instinct, and the site was healthy the following morning.

The morning after that, it was empty again.

All eight had been written with publishAt set to the day they were written. Every one of them was due immediately. The next build published the lot, the queue went to zero, and the site went back to being frozen — with the added confusion that the previous session's log said the shortage had been dealt with.

Adding articles is not the same as adding inventory. For a scheduled publisher, inventory is dates in the future. Eight articles all dated today is one day of content and a full day of work spent producing it.

The obvious repair — push the eight dates forward — is the wrong move, and this is worth stating plainly. They had already been published. Their URLs were live and returning 200. Changing publishAt to a future date would have removed those pages from the build and turned live URLs into 404s. You cannot unpublish your way back into a schedule. The fix was to write new articles with dates spread across the following weeks and leave the published ones alone.

The real problem: success on empty input

Both date bugs are ordinary. The reason they cost weeks is structural: the pipeline reports success identically whether it has work to do or not.

That is the same failure mode as a scheduled job that never starts — a fault whose signature is absence produces nothing for a failure-based monitor to catch. Here it is worse, because there is a green result every morning actively suggesting everything is fine.

What we added is a count rather than an alert:

  • how many unpublished articles remain per site
  • the next publishAt, and the last one

Two numbers per site, reported where someone reads them. A site whose future queue is empty is now visible before it goes quiet, not 28 days after.

The takeaways

A build that cannot fail cannot be your monitor. If empty input is a valid state, exit code 0 tells you nothing about whether the system is doing its job. Measure the output, not the exit status.

Handle dates a human typed as local dates. toISOString() on a calendar date is a bug waiting for a timezone.

For anything scheduled, count future items, not total items. "We have 30 articles" and "we have 30 days of articles" are different claims, and only one of them means the site keeps updating next month.

Get new posts by email

We email you only when a new post goes up here. You can unsubscribe at any time.

Privacy policy