{"title":"Scheduled Posts On A Static Site","html_url":"https://deallocatedobjects.com/posts/scheduled-posts-on-a-static-site","source_url":"","markdown_content":"Deallocated Objects is a static site. A Bun/TypeScript generator on my Mac turns a folder of markdown into HTML, a deploy script copies the files to the server, and nginx serves them. There's no app process for the blog, no database and nothing to restart.\n\nI still wanted to finish a post on a Sunday, set it for Wednesday at 8am Central, and have it appear Wednesday at 8am. The server never runs the generator to make that happen. It just copies some files when the clock says so.\n\n### A Future Date Means Unlisted\n\nEvery post has a `date` in its frontmatter. When that date is in the future, the post still gets a page, but it stays out of everything else. The whole rule is one line in `src/content.ts`:\n\n```ts title=\"src/content.ts\"\n// Scheduled posts: a future date means the page exists (noindex, unlinked) but the post stays out\n// of every list until a build runs after that time. BUILD_AT=<iso> previews another moment.\nexport const buildNow = () => (process.env.BUILD_AT ? +new Date(process.env.BUILD_AT) : Date.now());\nexport const isListed = (p: Post, now = buildNow()) => !p.draft && +new Date(p.date) <= now;\n```\n\n`orderPosts` filters on `isListed` before it sorts anything, and the homepage, archive, topic pages, feeds and sitemap are all built from that ordered list. So a scheduled post isn't in any of them. Its own page gets `<meta name=\"robots\" content=\"noindex\">`, no older/newer links, no markdown alternate, and a small banner at the top:\n\n> **Scheduled** for Wednesday, October 28 at 8:00 AM Central. This page is unlisted until then.\n\nI like having the page exist early. I can send the link to someone for a read, and any link that goes out a little ahead of time (a social post, a newsletter draft) already works.\n\n### Building The Future Ahead Of Time\n\nLook at `buildNow` again. The generator never asks the system clock directly. It asks `buildNow()`, and `BUILD_AT` overrides it. So \"what will the site look like Wednesday at 8:01am?\" is the same question as \"build the site with `BUILD_AT` set to Wednesday at 8:01am.\"\n\nThat's the entire scheduling engine. After the normal build and upload, `scripts/deploy.sh` finds every approved post with a future date and builds a complete copy of the site for one minute after each one goes live, each into its own folder:\n\n```bash title=\"scripts/deploy.sh\"\nrm -rf dist-scheduled && mkdir -p dist-scheduled\nfor t in $(bun -e 'const {loadContent}=await import(\"./src/content.ts\"); const {posts}=await loadContent(process.cwd()); const n=Date.now(); console.log([...new Set(posts.filter(p=>!p.draft && +new Date(p.date)>n).map(p=>Math.floor(+new Date(p.date)/1000)+60))].join(\" \"))'); do\n  echo \"==> scheduled snapshot for $(date -u -r \"$t\" +%FT%TZ)\"\n  BUILD_AT=\"$(date -u -r \"$t\" +%FT%TZ)\" DIST_DIR=\"$REPO/dist-scheduled/$t\" bun src/build.ts >/dev/null\ndone\n```\n\nThe extra minute makes sure the post's own `date` is at or before `BUILD_AT`, so `isListed` says yes. The folder names are Unix timestamps. Those snapshots get uploaded to `/opt/deallocated/scheduled/<unix time>/` on the server, next to the live site in `/opt/deallocated/site/`.\n\nEach snapshot is the whole site as it will be at that moment. In the Wednesday snapshot the post is on the homepage and in the feeds, the noindex and the banner are gone, and the post before it has a \"newer\" link pointing at it. Two posts scheduled for different days get two snapshots, and the later one includes both.\n\n### Swapping It In\n\nOn the server, a tiny shell script checks for snapshots whose time has passed:\n\n```sh title=\"deploy/deallocated-release.sh\"\nset -eu\ndir=/opt/deallocated/scheduled\nsite=/opt/deallocated/site\n[ -d \"$dir\" ] || exit 0\nnow=$(date +%s)\nfor d in $(ls -1 \"$dir\" | grep -E '^[0-9]+$' | sort -n); do\n  [ \"$d\" -le \"$now\" ] || continue\n  rsync -a --delete \"$dir/$d/\" \"$site/\"\n  rm -rf \"${dir:?}/$d\"\n  logger -t deallocated-release \"released scheduled snapshot $d ($(date -u -d @\"$d\" +%FT%TZ))\"\ndone\n```\n\nA systemd timer runs it every two minutes:\n\n```ini title=\"deploy/deallocated-release.timer\"\n[Timer]\nOnCalendar=*:0/2\nPersistent=true\n```\n\nThe service it starts runs as `deallocated`, the site's own unprivileged user, with `NoNewPrivileges=yes`, `ProtectSystem=strict` and `ReadWritePaths=/opt/deallocated`. It can rewrite the blog's files and nothing else on the box.\n\n`Persistent=true` covers the case where the server is down or rebooting at the moment a timer should have fired. systemd remembers the missed run and does it as soon as it's back up, and since the script looks for any snapshot at or before now (in time order), nothing gets skipped. If two are due at once, they're applied oldest first and the newest one wins, which is the right end state.\n\n### Why rsync Is Good Enough Here\n\n`rsync --delete` from one local folder to another isn't an atomic directory swap. While it runs, some files are new and some are old. I thought about flipping a symlink between two release folders instead and decided it wasn't worth it for this site. rsync writes each file to a temporary name and renames it into place, so nginx never serves half a file, and it's a copy between two folders on the same disk, so it's over quickly. The only thing a reader could catch mid-copy is a new homepage linking to a post page that still has its \"Scheduled\" banner, and that's harmless.\n\n### Every Deploy Starts Fresh\n\nThe deploy script rebuilds the whole `dist-scheduled/` folder every time and uploads it with `--delete`. That's what keeps the schedule honest without any bookkeeping:\n\n- If I fix a typo in a scheduled post and deploy, its snapshot is rebuilt with the fix.\n- If I publish something else in between, every later snapshot includes it, because they're all built from the current content.\n- If I move a post to a different day or pull it back to a draft, its old snapshot just isn't in the new upload, so `--delete` removes it from the server.\n\nThere's never a snapshot on the server that doesn't match what's in the repo.\n\n### Building On Your Mac, Serving From The Server\n\nThe obvious alternative is putting the generator on the server and running a build on a timer. I'd rather not. The generator and its tools live on my Mac, and that's where I want them: the AVIF and WebP images are encoded with macOS `sips` plus `cwebp` and `avifenc` from Homebrew, with a cache in `~/Library/Caches`, and the deploy's strict link check runs there too. The server's job for the blog stays small. It serves files, and every two minutes it might copy some. Nothing on it ever builds the site, so nothing on it can build the site differently than my Mac does.\n\nPre-building also means I can open a snapshot folder and look at exactly what will go live on Wednesday before it happens.\n\n### Everything Downstream Follows The Feed\n\nThe rest of publishing keys off one file: the live `feed.json` in `/opt/deallocated/site/`. Since the release script is the thing that changes it, everything downstream follows automatically.\n\nThe newsletter service has a pm2 cron (`newsletter/autosend.ts`) that runs every 10 minutes, reads `feed.json` straight off the disk, and emails any post that's new since it started watching. It ignores anything more than three days old, so a rebuild can never re-send an old post.\n\nThe ActivityPub side of the same service has a 5-minute tick in `newsletter/server.ts` that does the same thing for followers:\n\n```ts title=\"newsletter/server.ts\"\nconst apTick = async () => {\n  try { for (const item of (await readFeed()).items) if (item.date_published > apSince! && ap.publish(item)) console.log(`ap: published ${item.url}`); }\n  catch (e) { console.error(`ap tick: ${(e as Error).message}`); }\n};\nvoid apTick(); setInterval(apTick, 5 * 60e3);\n```\n\nSocial announcements work the other way around. `scripts/schedule-social.ts` puts them on Octopost's calendar ahead of time, two minutes after each post's go-live time, with the title and description up top and the link in a threaded reply. If a post's date changes it moves them, and if a post goes back to draft it cancels them. What got scheduled where is saved in `content/announced.json`, which is committed to the repo.\n\n### Drafts And Approval\n\nNew posts start with `draft: true`. Drafts get a page too, with a different banner (\"Draft preview. Not published; only people with this link can see it.\") and the same noindex. They never get a snapshot, because `isListed` is false for a draft at any time.\n\nWhen I'm happy with one, `scripts/approve.ts` turns it into a scheduled post:\n\n```bash\nbun scripts/approve.ts scheduled-posts-on-a-static-site --at \"wednesday 8am\"\n```\n\nIt reads times in Central, removes `draft: true`, writes the real `date`, generates the link-preview card and prints the next two commands: commit and run `scripts/deploy.sh`, then `bun scripts/schedule-social.ts`. There's also `--now` for the next deploy and `--undo` to put a post back to draft.\n\n### The Calendar Page\n\nThe build also writes a private editorial calendar at `/review/<token>`. The token lives in a file in the repo, the page is noindex and nofollow, it isn't linked from anywhere, and `/review/` is disallowed in `robots.txt`. It lists the drafts waiting for approval (with the exact `approve.ts` command for each), the scheduled queue with word counts and where each post will be announced, a five-week grid of color-coded chips for drafts, scheduled posts and published ones, and the last few posts that went out.\n\nSince it's built by the same generator with the same `NOW`, each snapshot's copy of the calendar is correct for its moment too. On Wednesday at 8:02am, this post's chip turns from \"scheduled\" to \"published\" without anyone touching it.","html_content":"<p>Deallocated Objects is a static site. A Bun/TypeScript generator on my Mac turns a folder of markdown into HTML, a deploy script copies the files to the server, and nginx serves them. There’s no app process for the blog, no database and nothing to restart.</p>\n<p>I still wanted to finish a post on a Sunday, set it for Wednesday at 8am Central, and have it appear Wednesday at 8am. The server never runs the generator to make that happen. It just copies some files when the clock says so.</p>\n<h2 id=\"a-future-date-means-unlisted\" data-level=\"3\">A Future Date Means Unlisted</h2>\n<p>Every post has a <code>date</code> in its frontmatter. When that date is in the future, the post still gets a page, but it stays out of everything else. The whole rule is one line in <code>src/content.ts</code>:</p>\n<figure class=\"code\" data-lang=\"typescript\"><figcaption><span class=\"file\">src/content.ts</span><span class=\"lang\">TypeScript</span></figcaption><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#6A737D;--shiki-dark:#6A737D\">// Scheduled posts: a future date means the page exists (noindex, unlinked) but the post stays out</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#6A737D;--shiki-dark:#6A737D\">// of every list until a build runs after that time. BUILD_AT=&#x3C;iso> previews another moment.</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">export</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> const</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> buildNow</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> =</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> () </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=></span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> (process.env.</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">BUILD_AT</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> ?</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> +new</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> Date</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(process.env.</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">BUILD_AT</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">) </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">:</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> Date.</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">now</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">());</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">export</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> const</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> isListed</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> =</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> (</span><span style=\"--shiki-light:#E36209;--shiki-dark:#FFAB70\">p</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">:</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> Post</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">, </span><span style=\"--shiki-light:#E36209;--shiki-dark:#FFAB70\">now</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> =</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> buildNow</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">()) </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=></span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> !</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">p.draft </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">&#x26;&#x26;</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> +new</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> Date</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(p.date) </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">&#x3C;=</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> now;</span></span></code></pre></figure><p><code>orderPosts</code> filters on <code>isListed</code> before it sorts anything, and the homepage, archive, topic pages, feeds and sitemap are all built from that ordered list. So a scheduled post isn’t in any of them. Its own page gets <code>&lt;meta name=&quot;robots&quot; content=&quot;noindex&quot;&gt;</code>, no older/newer links, no markdown alternate, and a small banner at the top:</p>\n<blockquote>\n<p><strong>Scheduled</strong> for Wednesday, October 28 at 8:00 AM Central. This page is unlisted until then.</p>\n</blockquote>\n<p>I like having the page exist early. I can send the link to someone for a read, and any link that goes out a little ahead of time (a social post, a newsletter draft) already works.</p>\n<h2 id=\"building-the-future-ahead-of-time\" data-level=\"3\">Building The Future Ahead Of Time</h2>\n<p>Look at <code>buildNow</code> again. The generator never asks the system clock directly. It asks <code>buildNow()</code>, and <code>BUILD_AT</code> overrides it. So “what will the site look like Wednesday at 8:01am?” is the same question as “build the site with <code>BUILD_AT</code> set to Wednesday at 8:01am.”</p>\n<p>That’s the entire scheduling engine. After the normal build and upload, <code>scripts/deploy.sh</code> finds every approved post with a future date and builds a complete copy of the site for one minute after each one goes live, each into its own folder:</p>\n<figure class=\"code\" data-lang=\"bash\"><figcaption><span class=\"file\">scripts/deploy.sh</span><span class=\"lang\">Shell</span></figcaption><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">rm</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -rf</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> dist-scheduled</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> &#x26;&#x26; </span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">mkdir</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -p</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> dist-scheduled</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">for</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> t </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">in</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> $(</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">bun</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -e</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> 'const {loadContent}=await import(\"./src/content.ts\"); const {posts}=await loadContent(process.cwd()); const n=Date.now(); console.log([...new Set(posts.filter(p=>!p.draft &#x26;&#x26; +new Date(p.date)>n).map(p=>Math.floor(+new Date(p.date)/1000)+60))].join(\" \"))'</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">); </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">do</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">  echo</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"==> scheduled snapshot for $(</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">date</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -u</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -r</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$t</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\" +%FT%TZ)\"</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">  BUILD_AT</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"$(</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">date</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -u</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -r</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$t</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\" +%FT%TZ)\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> DIST_DIR</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$REPO</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">/dist-scheduled/</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$t</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> bun</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> src/build.ts</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> ></span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">/dev/null</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">done</span></span></code></pre></figure><p>The extra minute makes sure the post’s own <code>date</code> is at or before <code>BUILD_AT</code>, so <code>isListed</code> says yes. The folder names are Unix timestamps. Those snapshots get uploaded to <code>/opt/deallocated/scheduled/&lt;unix time&gt;/</code> on the server, next to the live site in <code>/opt/deallocated/site/</code>.</p>\n<p>Each snapshot is the whole site as it will be at that moment. In the Wednesday snapshot the post is on the homepage and in the feeds, the noindex and the banner are gone, and the post before it has a “newer” link pointing at it. Two posts scheduled for different days get two snapshots, and the later one includes both.</p>\n<h2 id=\"swapping-it-in\" data-level=\"3\">Swapping It In</h2>\n<p>On the server, a tiny shell script checks for snapshots whose time has passed:</p>\n<figure class=\"code\" data-lang=\"bash\"><figcaption><span class=\"file\">deploy/deallocated-release.sh</span><span class=\"lang\">Shell</span></figcaption><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">set</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -eu</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">dir</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">/opt/deallocated/scheduled</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">site</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">/opt/deallocated/site</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">[ </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">-d</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$dir</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> ] </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">||</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> exit</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> 0</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">now</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$(</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">date</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> +%s</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">for</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> d </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">in</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> $(</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">ls</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -1</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$dir</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> |</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> grep</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -E</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> '^[0-9]+$'</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> |</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> sort</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -n</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">); </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">do</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">  [ </span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$d</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> -le</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$now</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> ] </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">||</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> continue</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">  rsync</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -a</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> --delete</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$dir</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">/</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$d</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">/\"</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$site</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">/\"</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">  rm</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -rf</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"${</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">dir</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">:?</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">}/</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$d</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">  logger</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -t</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> deallocated-release</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"released scheduled snapshot </span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$d</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> ($(</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">date</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -u</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> -d</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> @\"</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">$d</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">\" +%FT%TZ))\"</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">done</span></span></code></pre></figure><p>A systemd timer runs it every two minutes:</p>\n<figure class=\"code\" data-lang=\"ini\"><figcaption><span class=\"file\">deploy/deallocated-release.timer</span><span class=\"lang\">INI</span></figcaption><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">[Timer]</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">OnCalendar</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">=*:0/2</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">Persistent</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">=true</span></span></code></pre></figure><p>The service it starts runs as <code>deallocated</code>, the site’s own unprivileged user, with <code>NoNewPrivileges=yes</code>, <code>ProtectSystem=strict</code> and <code>ReadWritePaths=/opt/deallocated</code>. It can rewrite the blog’s files and nothing else on the box.</p>\n<p><code>Persistent=true</code> covers the case where the server is down or rebooting at the moment a timer should have fired. systemd remembers the missed run and does it as soon as it’s back up, and since the script looks for any snapshot at or before now (in time order), nothing gets skipped. If two are due at once, they’re applied oldest first and the newest one wins, which is the right end state.</p>\n<h2 id=\"why-rsync-is-good-enough-here\" data-level=\"3\">Why rsync Is Good Enough Here</h2>\n<p><code>rsync --delete</code> from one local folder to another isn’t an atomic directory swap. While it runs, some files are new and some are old. I thought about flipping a symlink between two release folders instead and decided it wasn’t worth it for this site. rsync writes each file to a temporary name and renames it into place, so nginx never serves half a file, and it’s a copy between two folders on the same disk, so it’s over quickly. The only thing a reader could catch mid-copy is a new homepage linking to a post page that still has its “Scheduled” banner, and that’s harmless.</p>\n<h2 id=\"every-deploy-starts-fresh\" data-level=\"3\">Every Deploy Starts Fresh</h2>\n<p>The deploy script rebuilds the whole <code>dist-scheduled/</code> folder every time and uploads it with <code>--delete</code>. That’s what keeps the schedule honest without any bookkeeping:</p>\n<ul>\n<li>If I fix a typo in a scheduled post and deploy, its snapshot is rebuilt with the fix.</li>\n<li>If I publish something else in between, every later snapshot includes it, because they’re all built from the current content.</li>\n<li>If I move a post to a different day or pull it back to a draft, its old snapshot just isn’t in the new upload, so <code>--delete</code> removes it from the server.</li>\n</ul>\n<p>There’s never a snapshot on the server that doesn’t match what’s in the repo.</p>\n<h2 id=\"building-on-your-mac-serving-from-the-server\" data-level=\"3\">Building On Your Mac, Serving From The Server</h2>\n<p>The obvious alternative is putting the generator on the server and running a build on a timer. I’d rather not. The generator and its tools live on my Mac, and that’s where I want them: the AVIF and WebP images are encoded with macOS <code>sips</code> plus <code>cwebp</code> and <code>avifenc</code> from Homebrew, with a cache in <code>~/Library/Caches</code>, and the deploy’s strict link check runs there too. The server’s job for the blog stays small. It serves files, and every two minutes it might copy some. Nothing on it ever builds the site, so nothing on it can build the site differently than my Mac does.</p>\n<p>Pre-building also means I can open a snapshot folder and look at exactly what will go live on Wednesday before it happens.</p>\n<h2 id=\"everything-downstream-follows-the-feed\" data-level=\"3\">Everything Downstream Follows The Feed</h2>\n<p>The rest of publishing keys off one file: the live <code>feed.json</code> in <code>/opt/deallocated/site/</code>. Since the release script is the thing that changes it, everything downstream follows automatically.</p>\n<p>The newsletter service has a pm2 cron (<code>newsletter/autosend.ts</code>) that runs every 10 minutes, reads <code>feed.json</code> straight off the disk, and emails any post that’s new since it started watching. It ignores anything more than three days old, so a rebuild can never re-send an old post.</p>\n<p>The ActivityPub side of the same service has a 5-minute tick in <code>newsletter/server.ts</code> that does the same thing for followers:</p>\n<figure class=\"code\" data-lang=\"typescript\"><figcaption><span class=\"file\">newsletter/server.ts</span><span class=\"lang\">TypeScript</span></figcaption><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">const</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> apTick</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> =</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> async</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> () </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=></span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">  try</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> { </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">for</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> (</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">const</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> item</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> of</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> (</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">await</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> readFeed</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">()).items) </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">if</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> (item.date_published </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">></span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> apSince</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">!</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> &#x26;&#x26;</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> ap.</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">publish</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(item)) console.</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">log</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">`ap: published ${</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">item</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">.</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">url</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">}`</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">); }</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">  catch</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> (e) { console.</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">error</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">`ap tick: ${</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">(</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">e</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> as</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> Error</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">).</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">message</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">}`</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">); }</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">};</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">void</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> apTick</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(); </span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">setInterval</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(apTick, </span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">5</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> *</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> 60e3</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">);</span></span></code></pre></figure><p>Social announcements work the other way around. <code>scripts/schedule-social.ts</code> puts them on Octopost’s calendar ahead of time, two minutes after each post’s go-live time, with the title and description up top and the link in a threaded reply. If a post’s date changes it moves them, and if a post goes back to draft it cancels them. What got scheduled where is saved in <code>content/announced.json</code>, which is committed to the repo.</p>\n<h2 id=\"drafts-and-approval\" data-level=\"3\">Drafts And Approval</h2>\n<p>New posts start with <code>draft: true</code>. Drafts get a page too, with a different banner (“Draft preview. Not published; only people with this link can see it.”) and the same noindex. They never get a snapshot, because <code>isListed</code> is false for a draft at any time.</p>\n<p>When I’m happy with one, <code>scripts/approve.ts</code> turns it into a scheduled post:</p>\n<figure class=\"code\" data-lang=\"bash\"><figcaption><span class=\"lang\">Shell</span></figcaption><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">bun</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> scripts/approve.ts</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> scheduled-posts-on-a-static-site</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> --at</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> \"wednesday 8am\"</span></span></code></pre></figure><p>It reads times in Central, removes <code>draft: true</code>, writes the real <code>date</code>, generates the link-preview card and prints the next two commands: commit and run <code>scripts/deploy.sh</code>, then <code>bun scripts/schedule-social.ts</code>. There’s also <code>--now</code> for the next deploy and <code>--undo</code> to put a post back to draft.</p>\n<h2 id=\"the-calendar-page\" data-level=\"3\">The Calendar Page</h2>\n<p>The build also writes a private editorial calendar at <code>/review/&lt;token&gt;</code>. The token lives in a file in the repo, the page is noindex and nofollow, it isn’t linked from anywhere, and <code>/review/</code> is disallowed in <code>robots.txt</code>. It lists the drafts waiting for approval (with the exact <code>approve.ts</code> command for each), the scheduled queue with word counts and where each post will be announced, a five-week grid of color-coded chips for drafts, scheduled posts and published ones, and the last few posts that went out.</p>\n<p>Since it’s built by the same generator with the same <code>NOW</code>, each snapshot’s copy of the calendar is correct for its moment too. On Wednesday at 8:02am, this post’s chip turns from “scheduled” to “published” without anyone touching it.</p>","created_at":"2026-10-28T13:00:00.000Z","updated_at":"2026-10-28T13:00:00.000Z"}