From 623ae6a16af0070695c082da66f533852484160a Mon Sep 17 00:00:00 2001 From: soloturn Date: Wed, 11 Nov 2026 20:48:49 +0100 Subject: [PATCH 1/2] feat: hide blog posts dated in the future and publish them when they come due getPosts() filters on the date and the assets copy skips hidden posts; `astro dev` still shows them. A daily check job builds only if a post's date passed since the last successful deploy. Refs #215. Co-Authored-By: soloturn --- .github/workflows/deploy.yml | 19 +++++++++++++++ README.md | 3 ++- docs/project-deployment.md | 2 +- eslint.config.js | 2 +- scripts/posts-due.mjs | 42 +++++++++++++++++++++++++++++++++ src/integrations/blog-assets.ts | 15 ++++++++---- src/lib/blog.ts | 4 +++- src/lib/published.ts | 3 +++ 8 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 scripts/posts-due.mjs create mode 100644 src/lib/published.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 15349803..423e26c2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,6 +4,9 @@ on: push: branches: - master + schedule: + # daily check; the build only runs if a scheduled post has come due + - cron: "0 2 * * *" workflow_dispatch: permissions: @@ -17,7 +20,23 @@ concurrency: cancel-in-progress: true jobs: + check: + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + outputs: + publish: ${{ steps.due.outputs.publish }} + steps: + - uses: actions/checkout@v7 + - id: due + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + run: node scripts/posts-due.mjs build: + needs: check + if: needs.check.outputs.publish == 'true' runs-on: ubuntu-latest env: GITHUB_TOKEN: ${{ secrets.MODULE_FETCH_GITHUB_TOKEN }} diff --git a/README.md b/README.md index 66324c64..b47aa9e9 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,8 @@ THe lower part is your blog post content. You'll also see an example of how to i - `description` is a short one or two line summary of your blog post - `author` is your name / nickname / alias - we recommend using either your GitHub or Discord name - `date` is the date you want your blog post to be published aka. go online (in ISO form: `YYYY-MM-DD`) - (this feature is no longer supported, your blog post will always be visible irrespective of the date you set) + A post dated in the future stays hidden until the first build on or after that date. A daily check at 02:00 UTC starts that build on the day a post comes due; to publish sooner, run the "Publish website on GitHub Pages" workflow from the Actions tab. + `npm run dev` shows scheduled posts so you can preview them. - `tags` should always be an array, multiple values are possible (please tag TeraSaturday posts with "TeraSaturday") diff --git a/docs/project-deployment.md b/docs/project-deployment.md index 19278f89..2eb7f07e 100644 --- a/docs/project-deployment.md +++ b/docs/project-deployment.md @@ -1,6 +1,6 @@ # Deployment -The website is deployed to [GitHub Pages](https://docs.github.com/en/pages/getting-started-with-github-pages/about-github-pages) by the `deploy.yml` workflow on every push to `master` (or manually from the Actions tab). +The website is deployed to [GitHub Pages](https://docs.github.com/en/pages/getting-started-with-github-pages/about-github-pages) by the `deploy.yml` workflow on every push to `master`, or manually from the Actions tab. A daily check at 02:00 UTC (`scripts/posts-due.mjs`) also starts a build when a blog post's date has passed since the last successful deploy, and does nothing otherwise. The workflow installs dependencies with `npm ci`, runs `npm run build` with the `MODULE_FETCH_GITHUB_TOKEN` secret exposed as `GITHUB_TOKEN`, uploads `dist/` with `actions/upload-pages-artifact`, and publishes it with `actions/deploy-pages`. The site is served from the domain root, so no base path is configured. diff --git a/eslint.config.js b/eslint.config.js index 37f476ce..cf50c61f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -10,7 +10,7 @@ export default [ ...tseslint.configs.recommended, ...astro.configs.recommended, { - files: ["**/*.{js,jsx,ts}"], + files: ["**/*.{js,jsx,mjs,ts}"], languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } }, globals: { ...globals.browser, ...globals.node }, diff --git a/scripts/posts-due.mjs b/scripts/posts-due.mjs new file mode 100644 index 00000000..5cfdadc5 --- /dev/null +++ b/scripts/posts-due.mjs @@ -0,0 +1,42 @@ +// Decides whether a deploy run has anything to publish. Scheduled runs only build +// when a post's date has passed since the last successful deploy; every other +// trigger (push, manual) always builds. Writes `publish=true|false` to $GITHUB_OUTPUT. +import { execFileSync } from "node:child_process"; +import { appendFileSync, readdirSync, readFileSync } from "node:fs"; + +function lastSuccessfulDeploy() { + const repo = process.env.GITHUB_REPOSITORY; + const json = execFileSync( + "gh", + [ + ...["run", "list", "--workflow", "deploy.yml", "--status", "success"], + ...["--limit", "1", "--json", "createdAt"], + ...(repo ? ["--repo", repo] : []), + ], + { encoding: "utf8" }, + ); + const [run] = JSON.parse(json); + return run ? new Date(run.createdAt) : null; +} + +function postDates() { + return readdirSync("blog").flatMap((dir) => { + try { + const text = readFileSync(`blog/${dir}/index.md`, "utf8"); + const date = /^date:\s*["']?(.*?)["']?\s*$/m.exec(text)?.[1]; + return date ? [new Date(date)] : []; + } catch { + return []; + } + }); +} + +let publish = true; +if (process.env.EVENT === "schedule") { + const since = lastSuccessfulDeploy(); + const now = new Date(); + publish = since === null || postDates().some((d) => d > since && d <= now); +} + +console.log(`publish=${publish}`); +if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `publish=${publish}\n`); diff --git a/src/integrations/blog-assets.ts b/src/integrations/blog-assets.ts index 21574909..cc9889e6 100644 --- a/src/integrations/blog-assets.ts +++ b/src/integrations/blog-assets.ts @@ -4,14 +4,21 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { AstroIntegration } from "astro"; import kebabCase from "lodash.kebabcase"; +import { isPublished } from "../lib/published"; -/** Map of post slug -> post directory; slug = kebab-cased frontmatter title. */ -async function postDirs(blogDir: string): Promise> { +/** + * Map of post slug -> post directory; slug = kebab-cased frontmatter title. + * With `publishedOnly`, posts dated in the future are left out. + */ +async function postDirs(blogDir: string, publishedOnly = false): Promise> { const dirs = new Map(); for (const dir of await readdir(blogDir)) { const md = path.join(blogDir, dir, "index.md"); if (!existsSync(md)) continue; - const title = /^title:\s*["']?(.*?)["']?\s*$/m.exec(await readFile(md, "utf8"))?.[1]; + const text = await readFile(md, "utf8"); + const title = /^title:\s*["']?(.*?)["']?\s*$/m.exec(text)?.[1]; + const date = /^date:\s*["']?(.*?)["']?\s*$/m.exec(text)?.[1]; + if (publishedOnly && date && !isPublished(new Date(date))) continue; if (title) dirs.set(kebabCase(title), path.join(blogDir, dir)); } return dirs; @@ -44,7 +51,7 @@ export default function blogAssets(): AstroIntegration { }, "astro:build:done": async ({ dir, logger }) => { const out = fileURLToPath(dir); - const dirs = await postDirs(path.join(root, "blog")); + const dirs = await postDirs(path.join(root, "blog"), true); for (const [slug, src] of dirs) { await cp(src, path.join(out, "blog", slug), { recursive: true, diff --git a/src/lib/blog.ts b/src/lib/blog.ts index f4f14a94..4f1a51d0 100644 --- a/src/lib/blog.ts +++ b/src/lib/blog.ts @@ -1,6 +1,7 @@ import { getImage } from "astro:assets"; import { getCollection, type CollectionEntry } from "astro:content"; import { excerpt } from "./format"; +import { isPublished } from "./published"; export type Post = CollectionEntry<"blog">; @@ -17,7 +18,8 @@ export interface CardData { } export async function getPosts(): Promise { - const posts = await getCollection("blog"); + // `astro dev` also lists scheduled posts, so authors can preview them. + const posts = await getCollection("blog", (p) => import.meta.env.DEV || isPublished(p.data.date)); return posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime()); } diff --git a/src/lib/published.ts b/src/lib/published.ts new file mode 100644 index 00000000..c7fd0d44 --- /dev/null +++ b/src/lib/published.ts @@ -0,0 +1,3 @@ +/** A post is live once its date has arrived; later-dated posts wait for a build on or after that date. */ +export const isPublished = (date: Date, now: Date = new Date()): boolean => + date.getTime() <= now.getTime(); From 2a1731bc63299275ab098ede2575292bad365910 Mon Sep 17 00:00:00 2001 From: soloturn Date: Wed, 11 Nov 2026 20:51:07 +0100 Subject: [PATCH 2/2] feat: also rebuild when the last deploy is over 30 days old Module and Good First Issue data only refresh on a build, so a quiet repo let them go stale. The daily check now also builds once the last successful deploy passes REFRESH_DAYS (default 30). Co-Authored-By: soloturn --- .github/workflows/deploy.yml | 2 +- docs/project-deployment.md | 2 +- scripts/posts-due.mjs | 8 ++++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 423e26c2..f152dcf5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,7 +5,7 @@ on: branches: - master schedule: - # daily check; the build only runs if a scheduled post has come due + # daily check; builds only if a scheduled post came due or the last deploy is 30+ days old - cron: "0 2 * * *" workflow_dispatch: diff --git a/docs/project-deployment.md b/docs/project-deployment.md index 2eb7f07e..d0c9ef2b 100644 --- a/docs/project-deployment.md +++ b/docs/project-deployment.md @@ -1,6 +1,6 @@ # Deployment -The website is deployed to [GitHub Pages](https://docs.github.com/en/pages/getting-started-with-github-pages/about-github-pages) by the `deploy.yml` workflow on every push to `master`, or manually from the Actions tab. A daily check at 02:00 UTC (`scripts/posts-due.mjs`) also starts a build when a blog post's date has passed since the last successful deploy, and does nothing otherwise. +The website is deployed to [GitHub Pages](https://docs.github.com/en/pages/getting-started-with-github-pages/about-github-pages) by the `deploy.yml` workflow on every push to `master`, or manually from the Actions tab. A daily check at 02:00 UTC (`scripts/posts-due.mjs`) also starts a build when a blog post's date has passed since the last successful deploy, or when that deploy is over 30 days old (which refreshes the module and issue data), and does nothing otherwise. The workflow installs dependencies with `npm ci`, runs `npm run build` with the `MODULE_FETCH_GITHUB_TOKEN` secret exposed as `GITHUB_TOKEN`, uploads `dist/` with `actions/upload-pages-artifact`, and publishes it with `actions/deploy-pages`. The site is served from the domain root, so no base path is configured. diff --git a/scripts/posts-due.mjs b/scripts/posts-due.mjs index 5cfdadc5..04609550 100644 --- a/scripts/posts-due.mjs +++ b/scripts/posts-due.mjs @@ -1,9 +1,12 @@ // Decides whether a deploy run has anything to publish. Scheduled runs only build -// when a post's date has passed since the last successful deploy; every other +// when a post's date has passed since the last successful deploy, or when that +// deploy is over REFRESH_DAYS old (to refresh module and issue data); every other // trigger (push, manual) always builds. Writes `publish=true|false` to $GITHUB_OUTPUT. import { execFileSync } from "node:child_process"; import { appendFileSync, readdirSync, readFileSync } from "node:fs"; +const REFRESH_DAYS = Number(process.env.REFRESH_DAYS ?? 30); + function lastSuccessfulDeploy() { const repo = process.env.GITHUB_REPOSITORY; const json = execFileSync( @@ -35,7 +38,8 @@ let publish = true; if (process.env.EVENT === "schedule") { const since = lastSuccessfulDeploy(); const now = new Date(); - publish = since === null || postDates().some((d) => d > since && d <= now); + const stale = since !== null && now - since > REFRESH_DAYS * 24 * 3600e3; + publish = since === null || stale || postDates().some((d) => d > since && d <= now); } console.log(`publish=${publish}`);