diff --git a/.gitea/workflows/scheduled-deploy.yml b/.gitea/workflows/scheduled-deploy.yml new file mode 100644 index 0000000..a06cebc --- /dev/null +++ b/.gitea/workflows/scheduled-deploy.yml @@ -0,0 +1,49 @@ +# Daily rebuild so date-scheduled posts go live on their pubDate. +# +# The site is static, so "now" is frozen at build time. This cron triggers a +# fresh Coolify deploy once a day; the rebuild re-evaluates the pubDate gate in +# src/utils/posts.ts, and any post whose date has arrived (and draft: false) +# appears. No file is mutated and no repo write access is needed — the job only +# pings the deploy hook. +# +# Setup (once): +# - A Gitea Actions runner must be registered and online for this repo/org, +# advertising a label that matches `runs-on` below (default act_runner maps +# `ubuntu-latest`; change it if your runner uses a different label). +# - Add a repo secret COOLIFY_DEPLOY_HOOK = the app's deploy URL from Coolify +# (app → Webhooks → "Deploy Webhook", e.g. +# https://coolify.example.com/api/v1/deploy?uuid=&force=false). +# - Add a repo secret COOLIFY_TOKEN = a Coolify API token (Bearer) with deploy +# permission. Required for the /api/v1/deploy endpoint. +# +# Your normal push-to-main deploy still runs independently; this only covers the +# "a new day rolled over" case. Trigger it by hand from the Actions tab (this +# workflow has workflow_dispatch) to smoke-test before relying on the cron. + +name: scheduled-deploy + +on: + schedule: + # 06:15 Europe/Berlin daily. Posts are eligible from 00:00 UTC on their + # pubDate; this morning rebuild publishes them. Adjust the time to taste. + - cron: 'TZ=Europe/Berlin 15 6 * * *' + workflow_dispatch: {} + +jobs: + trigger-deploy: + runs-on: ubuntu-latest + steps: + - name: Ping Coolify deploy hook + env: + COOLIFY_DEPLOY_HOOK: ${{ secrets.COOLIFY_DEPLOY_HOOK }} + COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }} + run: | + if [ -z "$COOLIFY_DEPLOY_HOOK" ]; then + echo "COOLIFY_DEPLOY_HOOK secret is not set." >&2 + exit 1 + fi + # Coolify's /api/v1/deploy endpoint is a GET authenticated with a + # Bearer token. -f makes curl exit non-zero on HTTP errors. + curl -fsS -X GET "$COOLIFY_DEPLOY_HOOK" \ + ${COOLIFY_TOKEN:+-H "Authorization: Bearer $COOLIFY_TOKEN"} + echo "Deploy triggered." diff --git a/src/pages/blog/[...slug].astro b/src/pages/blog/[...slug].astro index 11c3619..37ea0f1 100644 --- a/src/pages/blog/[...slug].astro +++ b/src/pages/blog/[...slug].astro @@ -1,10 +1,11 @@ --- -import { getCollection, render } from 'astro:content'; +import { render } from 'astro:content'; import BlogPost from '../../layouts/BlogPost.astro'; import type { GetStaticPaths } from 'astro'; +import { getPublishedPosts } from '../../utils/posts'; export const getStaticPaths = (async () => { - const posts = await getCollection('blog', ({ data }) => !data.draft); + const posts = await getPublishedPosts(); return posts.map((post) => ({ params: { slug: post.id }, props: { post }, diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro index 90bb741..11a2116 100644 --- a/src/pages/blog/index.astro +++ b/src/pages/blog/index.astro @@ -1,11 +1,9 @@ --- import BaseLayout from '../../layouts/BaseLayout.astro'; import FormattedDate from '../../components/FormattedDate.astro'; -import { getCollection } from 'astro:content'; +import { getPublishedPosts } from '../../utils/posts'; -const posts = (await getCollection('blog', ({ data }) => !data.draft)).sort( - (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf() -); +const posts = await getPublishedPosts(); --- diff --git a/src/pages/index.astro b/src/pages/index.astro index b249893..5a0cfe5 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -3,10 +3,9 @@ import BaseLayout from '../layouts/BaseLayout.astro'; import { Icon } from 'astro-icon/components'; import { getCollection } from 'astro:content'; import { SITE } from '../consts'; +import { getPublishedPosts } from '../utils/posts'; -const posts = (await getCollection('blog', ({ data }) => !data.draft)) - .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()) - .slice(0, 4); +const posts = (await getPublishedPosts()).slice(0, 4); const fmt = (d: Date) => d.toLocaleDateString(SITE.lang, { year: 'numeric', month: 'short' }); diff --git a/src/pages/rss.xml.js b/src/pages/rss.xml.js index f84d00d..5d41551 100644 --- a/src/pages/rss.xml.js +++ b/src/pages/rss.xml.js @@ -1,11 +1,9 @@ import rss from '@astrojs/rss'; -import { getCollection } from 'astro:content'; import { SITE } from '../consts'; +import { getPublishedPosts } from '../utils/posts'; export async function GET(context) { - const posts = (await getCollection('blog', ({ data }) => !data.draft)).sort( - (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf() - ); + const posts = await getPublishedPosts(); return rss({ title: SITE.title, diff --git a/src/pages/tags/[tag].astro b/src/pages/tags/[tag].astro index 0a7dcbf..29d4dc4 100644 --- a/src/pages/tags/[tag].astro +++ b/src/pages/tags/[tag].astro @@ -1,11 +1,11 @@ --- import BaseLayout from '../../layouts/BaseLayout.astro'; import FormattedDate from '../../components/FormattedDate.astro'; -import { getCollection } from 'astro:content'; import type { GetStaticPaths } from 'astro'; +import { getPublishedPosts } from '../../utils/posts'; export const getStaticPaths = (async () => { - const posts = await getCollection('blog', ({ data }) => !data.draft); + const posts = await getPublishedPosts(); const tags = [...new Set(posts.flatMap((p) => p.data.tags))]; return tags.map((tag) => ({ params: { tag }, diff --git a/src/utils/posts.ts b/src/utils/posts.ts new file mode 100644 index 0000000..a9aa71b --- /dev/null +++ b/src/utils/posts.ts @@ -0,0 +1,24 @@ +import { getCollection, type CollectionEntry } from 'astro:content'; + +/** + * Published blog posts, newest first. + * + * A post is published when it is not a draft AND its `pubDate` has arrived. + * That makes `pubDate` the single source of truth for scheduling: set a post + * to `draft: false` with a future date and it goes live on its own once a + * build runs on or after that day (see the daily deploy cron). + * + * In dev the gate is lifted entirely so drafts and future-scheduled posts can + * be previewed locally; production applies both the draft and date gates. + */ +export async function getPublishedPosts(): Promise[]> { + const now = Date.now(); + const posts = await getCollection('blog', ({ data }) => { + // Dev server: show everything, including drafts and not-yet-due posts. + if (import.meta.env.DEV) return true; + if (data.draft) return false; + if (data.pubDate.valueOf() > now) return false; + return true; + }); + return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); +}