# Writing and releasing a blog post How posts live, how they're scheduled, and how they go live. The short version: **a post is public when it is `draft: false` *and* its `pubDate` has arrived** — and a daily job rebuilds the site so "arrived" keeps advancing on its own. ## 1. Add the post Create a Markdown file in `src/content/blog/`, e.g. `src/content/blog/my-post.md`. The filename (minus `.md`) is the URL slug: `/blog/my-post/`. Frontmatter is validated at build time against `src/content.config.ts`: ```markdown --- title: Short, specific title description: >- One or two sentences. Shown in listings, the RSS feed, and social previews. pubDate: 2026-07-08 # ISO date. The publish gate (see below). # updatedDate: 2026-07-10 # optional; shown as "updated" tags: [android, calendula] # see the tag vocabulary below draft: true # true = never public; flip to false when ready --- Body in Markdown. ~80-char wrapped to match the other posts. ``` Voice conventions (match the existing posts): first-person, "constraint as design", link to standards/APIs inline, one punchy closing line. Reference other posts with root-relative links like `/blog/open-standards`. Established **tag vocabulary** — reuse these rather than inventing one-offs: `android`, `architecture`, `calendula`, `caldav`, `open-standards`, `open-source`, `accessibility`, `localization`, `meta`. ## 2. The three states The gate lives in `src/utils/posts.ts` (`getPublishedPosts()`), which every listing, the tag pages, the post routes, and `rss.xml` use. | `draft` | `pubDate` | Dev server | Production | | --- | --- | --- | --- | | `true` | any | visible (preview) | **hidden** | | `false` | future | visible (preview) | **hidden until the date** | | `false` | today / past | visible | **live** | So: - **`draft: true`** — work in progress or not yet approved. Never public. - **`draft: false` + future `pubDate`** — approved and *scheduled*. Publishes itself on that day (see §3). - **`draft: false` + past `pubDate`** — live now. Preview anything (including drafts and scheduled posts) locally with `npm run dev` — the dev server lifts the gate entirely. ## 3. How release actually happens The site is static, so "now" is frozen at each build. Two things trigger a rebuild: 1. **Push to `main`** → Coolify redeploys (the normal deploy). 2. **The daily cron** → `.gitea/workflows/scheduled-deploy.yml` runs at **06:15 Europe/Berlin**, pings the Coolify deploy hook, and the rebuild re-evaluates the date gate. This is what makes a future-dated post appear on its day without anyone touching the repo. The cron runs on the shared **`docker`** runner and needs two repo secrets set once (Gitea → Settings → Actions → Secrets): `COOLIFY_DEPLOY_HOOK` (the app's deploy URL) and `COOLIFY_TOKEN` (a Coolify API token, Bearer). ## 4. Publish checklist To schedule a post for its date (the normal path): 1. Set `draft: false`, leave the intended future `pubDate`. 2. Commit and get it onto `main` (branch → PR → merge; PRs via `tea pr create` / `tea pr merge`). Merging deploys, but the post stays hidden until its date. 3. Done — the daily cron publishes it on `pubDate`. To release a post **immediately**: 1. Set `draft: false` **and** `pubDate` to today (or a past date), so the gate passes now. 2. Merge to `main`. 3. Trigger a rebuild right away instead of waiting for the cron: ```sh tea actions workflows dispatch scheduled-deploy.yml --ref main ``` (`tea` may print `unexpected end of JSON input` — that's it mis-reading Gitea's empty success response; the run still starts. Check it with `tea actions runs list`.) 4. Coolify rebuilds; confirm at `https://jeanlucmakiola.de/blog//`. ## 5. Handy commands ```sh npm run dev # preview everything locally npm run build # production build (applies the gate) tea actions workflows list # see the deploy workflow tea actions workflows dispatch scheduled-deploy.yml --ref main # manual deploy tea actions runs list # watch run status/conclusion ``` ## 6. Gotchas - **Don't** put non-post `.md` files under `src/content/blog/` — the collection glob will try to parse them as posts and fail the build. Docs like this one live in `docs/`. - `pubDate` with no time resolves to `00:00 UTC` that day, so a post becomes eligible at UTC midnight; the morning cron then publishes it. Adjust the cron time in the workflow if you want a different local hour. - Timezone for the cron is pinned in the workflow (`TZ=Europe/Berlin`); the gate comparison itself uses the build machine's clock (UTC in CI).