diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 15349803..f152dcf5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,6 +4,9 @@ on: push: branches: - master + schedule: + # daily check; builds only if a scheduled post came due or the last deploy is 30+ days old + - 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..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). +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/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..04609550 --- /dev/null +++ b/scripts/posts-due.mjs @@ -0,0 +1,46 @@ +// 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, 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( + "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(); + 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}`); +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();