diff --git a/.github/workflows/refresh-community-data.yml b/.github/workflows/refresh-community-data.yml index 7eb1c59b7..7a5e2e819 100644 --- a/.github/workflows/refresh-community-data.yml +++ b/.github/workflows/refresh-community-data.yml @@ -34,6 +34,10 @@ jobs: - uses: actions/setup-node@v7 with: node-version-file: ".nvmrc" + cache: "npm" + + - name: Install dependencies + run: npm ci - name: Fetch and update discussion posts run: node scripts/refresh-discussions.mjs diff --git a/.github/workflows/refresh-community-sitemap.yml b/.github/workflows/refresh-community-sitemap.yml index a11967f88..18a7d0136 100644 --- a/.github/workflows/refresh-community-sitemap.yml +++ b/.github/workflows/refresh-community-sitemap.yml @@ -34,6 +34,10 @@ jobs: - uses: actions/setup-node@v7 with: node-version-file: ".nvmrc" + cache: "npm" + + - name: Install dependencies + run: npm ci - name: Generate community sitemap run: node scripts/generate-community-sitemap.mjs diff --git a/AGENTS.md b/AGENTS.md index 53d97b01e..bb6ccb632 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -372,6 +372,22 @@ See [`ADVENTURES.md`](ADVENTURES.md). Add/extend the YAML, add per-level `*-post The `off-on-dev` org restricts third-party actions. Permitted: `actions/checkout`, `actions/cache`, `actions/setup-node`, `actions/create-github-app-token`, `JamesIves/github-pages-deploy-action`, `marocchino/sticky-pull-request-comment`, `rossjrw/pr-preview-action`, `fsfe/reuse-action`, actions owned by `off-on-dev`, actions created by GitHub, and Marketplace-verified actions. `withastro/action` and `actions/deploy-pages` are **NOT allowlisted**. Before adding a `uses:`, verify it is permitted. +### Workflow convention: scripts/ require npm ci + +Any workflow job that invokes a script under `scripts/` via `node scripts/*.mjs` must run `npm ci` immediately after `setup-node`, before the first script step: + +```yaml +- uses: actions/setup-node@v7 + with: + node-version-file: ".nvmrc" + cache: "npm" + +- name: Install dependencies + run: npm ci +``` + +This is unconditional, even when the script currently uses only Node built-ins. Scripts acquire package imports over time; a workflow that skips `npm ci` breaks silently on the next scheduled run. With `cache: "npm"` a clean install takes a few seconds. + --- ## Before Submitting Code diff --git a/CLAUDE.md b/CLAUDE.md index 96829f7db..4c5330e04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -408,6 +408,22 @@ See [`ADVENTURES.md`](ADVENTURES.md). In brief: add/extend the YAML at `src/data The `off-on-dev` org restricts third-party actions. Permitted: `actions/checkout`, `actions/cache`, `actions/setup-node`, `actions/create-github-app-token`, `JamesIves/github-pages-deploy-action`, `marocchino/sticky-pull-request-comment`, `rossjrw/pr-preview-action`, `fsfe/reuse-action`, actions owned by `off-on-dev`, actions created by GitHub, and Marketplace-verified actions. **The official `withastro/action` and `actions/deploy-pages` are NOT allowlisted** — keep the JamesIves deploy flow. Before adding a `uses:`, verify it is permitted or use `gh`/shell. +### Workflow convention: scripts/ require npm ci + +Any workflow job that invokes a script under `scripts/` via `node scripts/*.mjs` must run `npm ci` immediately after `setup-node`, before the first script step: + +```yaml +- uses: actions/setup-node@v7 + with: + node-version-file: ".nvmrc" + cache: "npm" + +- name: Install dependencies + run: npm ci +``` + +This is unconditional, even when the script currently uses only Node built-ins. Scripts acquire package imports over time; a workflow that skips `npm ci` breaks silently on the next scheduled run. With `cache: "npm"` a clean install takes a few seconds. + --- ## Before Submitting Code diff --git a/scripts/sync-adventure.mjs b/scripts/sync-adventure.mjs index 320b27b57..cc51aefb0 100644 --- a/scripts/sync-adventure.mjs +++ b/scripts/sync-adventure.mjs @@ -60,9 +60,10 @@ function fail(msg) { process.exit(1); } -function currentMonth() { +export function currentMonth() { + const ABBR = ["JAN","FEB","MAR","APR","MAY","JUN","JUL","AUG","SEP","OCT","NOV","DEC"]; const d = new Date(); - return d.toLocaleString("en-GB", { month: "short" }).toUpperCase() + " " + d.getFullYear(); + return ABBR[d.getUTCMonth()] + " " + d.getUTCFullYear(); } function parseAdventureUrl(url) { @@ -594,7 +595,9 @@ async function main() { console.log(`\nDone: ${adventureName} (live: ${activeLevels.map((l) => l.level).join(", ")}${upcomingLevels.length > 0 ? ` | upcoming: ${upcomingLevels.map((u) => u.difficulty).join(", ")}` : ""})`); } -main().catch((err) => { - console.error(err); - process.exit(1); -}); +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/src/test/scripts/sync-adventure.test.ts b/src/test/scripts/sync-adventure.test.ts new file mode 100644 index 000000000..35b948c9b --- /dev/null +++ b/src/test/scripts/sync-adventure.test.ts @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2025 OffOn contributors +// SPDX-License-Identifier: MIT + +/** + * Unit tests for currentMonth() in scripts/sync-adventure.mjs. + * + * Non-vacuous check: the old implementation used + * new Date().toLocaleString("en-GB", { month: "short" }).toUpperCase() + * Node 26 CLDR returns "Sept" (4 letters) for September, so "SEPT 2026" + * fails the adventure schema regex /^[A-Z]{3} \d{4}$/. + * + * The it.each test pins the system clock to every month of the year so + * the suite catches a revert to toLocaleString regardless of when it runs. + * The old-implementation test always asserts "SEPT 2026" against the + * schema, providing a second revert-catch that does not depend on the clock. + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { currentMonth } from "../../../scripts/sync-adventure.mjs"; + +const MONTH_SCHEMA = /^[A-Z]{3} \d{4}$/; + +const ALL_MONTHS: [number, string][] = [ + [0, "JAN"], [1, "FEB"], [2, "MAR"], [3, "APR"], + [4, "MAY"], [5, "JUN"], [6, "JUL"], [7, "AUG"], + [8, "SEP"], [9, "OCT"], [10, "NOV"], [11, "DEC"], +]; + +describe("currentMonth", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it.each(ALL_MONTHS)( + "month %i (%s): produces correct abbreviation and passes schema regex", + (monthIndex, abbr) => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, monthIndex, 15)); + expect(currentMonth()).toBe(`${abbr} 2026`); + expect(currentMonth()).toMatch(MONTH_SCHEMA); + }, + ); + + it("old toLocaleString implementation produces 'SEPT 2026' for September, failing the schema", () => { + // Inline the old implementation so this assertion is always true regardless + // of the current month. A revert of the fix causes the it.each test above + // to fail for September; this test makes the cause immediately legible. + const oldImpl = (d: Date) => + d.toLocaleString("en-GB", { month: "short" }).toUpperCase() + + " " + + d.getFullYear(); + const result = oldImpl(new Date(2026, 8, 15)); + expect(result).toBe("SEPT 2026"); + expect(result).not.toMatch(MONTH_SCHEMA); + }); +});