A bug-fix release about restraint: what the publishing cron may announce, who may write the queue while it is being read, and what an emptied config section means. Most of it came out of one real incident and one beginner's first days with the engine, walked end to end.
An announcement that exists is never sent twice — from anywhere. Putting an old post through the queue — an archived thread given a page of its own, a release written up after the fact — used to drop years-old pages into a live timeline, and an announcement cannot be recalled. Two guards now, and they hold on every path: a post dated more than a day back publishes without announcing (the cron says so per post and names ./blog.sh toot <slug>; a person gets asked), and a post that already carries its announcement address is never announced again — not by the cron, not by publish, not by the standalone toot and bluesky commands, whichever network the address lives on. The replies under the original thread are the thing being protected; wanting a second announcement means deleting the address field from the post's JSON first, an edit deliberate enough to mean it. The cron also reads unlisted exactly as broadly as the builder that hides the post, so a flag a script wrote as "1" or "Yes" no longer puts a hidden page on a public timeline. A cron that was down longer than a day is the deliberate cost: those posts publish unannounced, out loud.
The queue can be rearranged while the cron is reading it. Every write the queue screen makes was guarded by a byte compare, but the compare and the write were separate instructions — and the run that changes these files with nobody at the keyboard arrives every fifteen minutes. A tick landing in between could revert a just-published post to a draft and drop its announcement URL. Every queue write takes the lock for the checks and the write together now — the queue screen's moves, the scheduling dialog, unscheduling — and one that meets a running publish writes nothing, says why, and answers scripts with the same busy exit code rebuild has always used. A lock held by a live but stuck process is named too: once the holder has been going longer than any legitimate run takes, the busy message says since when, instead of promising "in a minute" forever. And posts that come due together — the shape of a cron that was down for a morning — go out oldest first, not alphabetically.
check sees the links that resolve against the post. ./?item=another-post, a bare www.host.tld, an address with a stray quote in front — neither internal nor external until now, so no run ever looked at them, --online included. They are not dead links, which is why they outlive audits: a static host ignores the query string and answers 200 with the very page the reader is standing on. Found on an archive that had just been declared sound: 73 of them, in 61 posts.
An emptied section is an answer. A links: key with nothing under it crashed the build while doctor called the same file healthy; doctor failed installs over an emptied copyright or bio, both of which the templates support on purpose; an emptied about drew an empty card on every page; and four places promised that nav: [] removes the menu bar — it stays, because the search field lives in it. All of it agrees now: empty content takes its heading with it, a deleted block reads as an emptied one, and the docs say what actually happens.
The scheduler answers the person asking. The date prompt now says that a bare "18:00" means today — the fastest route to tonight used to be the one route nobody could see; a typo re-asks instead of ending the standalone schedule with the post untouched; and scheduling no longer rebuilds the site just to stamp a new date on a draft preview the publication throws away hours later. The setup wizard now offers publishing.slots right under the cron line it prints — the key that makes the scheduler offer times by itself used to exist only for whoever found it in the documentation. A slot offer that expires mid-dialog renews itself instead of advertising a time the future-guard refuses, and publish over a draft no build has ever seen builds the preview it is about to print.
A heading can be turned off. Emptying a section's content has taken its heading with it since 1.2 — but emptying the heading over content that stays drew a bare <h3></h3>, so a footer note without a title was not a thing a site could say. Now it is, for the links, the note and the social column alike; a site with headings renders byte for byte what it did. And doctor watches footer.note_heading with the other template texts — the shipped English "Found something here?" once sat over a Czech note on a live site for twelve days with every check green.
The first hour of an install stopped misleading. A fresh site printed https://example.com/… under every preview and publish — a domain the author does not own — while the finished build sat one ./blog.sh preview away; both lines now say so, with the address that opens. The deploy explained "the site goes nowhere" in English on a Czech install, through the one entry point that never loaded the translations. The editor template's body was English on every site. And the example config had chosen Mastodon for you, so the first scheduled post on a hand-configured install ended in "check the token" and cron failure mail; both networks ship commented out now, because a network is a choice.
Also: a config the filesystem refused to write (a root-owned .bak, most often) arrives as a sentence naming the file instead of a Ruby stack trace, and nothing is left half-written; a Surfer that is stopped or misaddressed gets the same treatment — a sentence naming SURFER_URL, with the deploy bookkeeping intact; and the Instagram import's "no posts found" hint names the subfolder each export format actually uses.
Upgrading — nothing to migrate. The one change that arrives unasked: a scheduled post dated more than a day back publishes without announcing, and says so.
Full notes in CHANGELOG.md.
MIT licensed. Documentation · blogsh.app