diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d0d6371 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.png binary diff --git a/.github/workflows/catalog.yml b/.github/workflows/catalog.yml new file mode 100644 index 0000000..b389c37 --- /dev/null +++ b/.github/workflows/catalog.yml @@ -0,0 +1,67 @@ +name: Catalog + +on: + pull_request: + push: + branches: [main] + schedule: + # Mondays, 00:00 UTC. + - cron: "0 0 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: catalog-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: pnpm/action-setup@v6 + with: + version: 11 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test + - run: pnpm validate + + stars: + needs: validate + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + ref: main + - uses: pnpm/action-setup@v6 + with: + version: 11 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm stars + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: pnpm validate + - name: Commit stars + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add apps + if ! git diff --cached --quiet; then + git commit -m 'Update GitHub stars' + git push origin HEAD:main + fi diff --git a/README.md b/README.md index 3884c9c..1fbe23f 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,10 @@ Unknown fields fail validation. `featured.json` lists app ids in order. Those apps come first. Maintainers choose them. The rest sort newest first, then by stars. +## Stars + +On every push to `main` and each Monday, CI asks GitHub for each GitHub source's stars and commits any change. `starsUpdatedAt` moves only when the count does. `pnpm stars` does the same locally with `GITHUB_TOKEN` set. + ## How the site uses this The Ely site pins a commit of this repo. Its build downloads that commit, runs `scripts/catalog.mjs`, and copies manifests, previews and READMEs. A catalog that fails here fails the site's build. diff --git a/package.json b/package.json index 2998f85..588dcc1 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "packageManager": "pnpm@11.28.3", "scripts": { "test": "node --test \"tests/*.test.mjs\"", - "validate": "node scripts/validate.mjs" + "validate": "node scripts/validate.mjs", + "stars": "node scripts/stars.mjs" }, "dependencies": { "marked": "18.0.14", diff --git a/scripts/stars.mjs b/scripts/stars.mjs new file mode 100644 index 0000000..1a3f82d --- /dev/null +++ b/scripts/stars.mjs @@ -0,0 +1,37 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { github, read } from "./catalog.mjs"; + +/** Sets each GitHub app's stars; the time moves only when the count does. Returns the ids changed. */ +export async function refresh(root, get, now) { + const changed = []; + for (const app of read(root).apps) { + const repo = github(app.source); + if (!repo) continue; + const stars = await get(repo); + if (!Number.isInteger(stars) || stars < 0) throw new Error(`${app.id}: GitHub gave ${stars} stars`); + if (app.stars === stars) continue; + const path = join(root, "apps", app.id, "manifest.json"); + const manifest = JSON.parse(readFileSync(path, "utf8")); + writeFileSync(path, `${JSON.stringify({ ...manifest, stars, starsUpdatedAt: now }, null, 2)}\n`); + console.log(`stars: ${app.id} ${app.stars ?? "none"} -> ${stars}`); + changed.push(app.id); + } + return changed; +} + +/** A repository's stars from GitHub's API. */ +async function stargazers({ owner, repo }) { + const token = process.env.GITHUB_TOKEN; + if (!token) throw new Error("stars: GITHUB_TOKEN is not set"); + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { + headers: { accept: "application/vnd.github+json", authorization: `Bearer ${token}`, "x-github-api-version": "2022-11-28" }, + }); + if (!res.ok) throw new Error(`stars: ${owner}/${repo} answered ${res.status} ${await res.text()}`); + return (await res.json()).stargazers_count; +} + +if (import.meta.main) { + const changed = await refresh(process.cwd(), stargazers, new Date().toISOString()); + console.log(`stars: ${changed.length} changed`); +} diff --git a/tests/catalog.test.mjs b/tests/catalog.test.mjs index 5fc055e..dda3417 100644 --- a/tests/catalog.test.mjs +++ b/tests/catalog.test.mjs @@ -3,52 +3,8 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { crc32, deflateSync } from "node:zlib"; import { read, weighted } from "../scripts/catalog.mjs"; - -function chunk(type, data) { - const head = Buffer.alloc(8); - head.writeUInt32BE(data.length); - head.write(type, 4, "latin1"); - const sum = Buffer.alloc(4); - sum.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data]))); - return Buffer.concat([head, data, sum]); -} - -/** A gray PNG `side` pixels square whose data is one pixel, its scanline led by `filter`. */ -function png(filter, side = 1) { - const header = Buffer.from([0, 0, 0, side, 0, 0, 0, side, 8, 0, 0, 0, 0]); - const image = deflateSync(Buffer.from([filter, 1])); - return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", header), chunk("IDAT", image), chunk("IEND", Buffer.alloc(0))]); -} -const PNG = png(0); -const broken = Buffer.from(PNG); -broken[broken.length - 20] ^= 0xff; - -const good = { - id: "tally", - name: "Tally", - author: "Ada", - description: "Counts things.", - category: "work", - platforms: ["macOS", "Linux"], - website: null, - source: "https://github.com/ada/tally", - publishedAt: "2026-10-04T00:00:00Z", - previews: ["preview0.png"], -}; - -/** A catalog in a temp folder: one app, its files, and featured.json. */ -function catalog({ app = good, folder = "tally", files = {}, featured = [] } = {}) { - const root = mkdtempSync(join(tmpdir(), "showcase-")); - const dir = join(root, "apps", folder); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "manifest.json"), JSON.stringify(app)); - writeFileSync(join(dir, "preview0.png"), PNG); - for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body); - writeFileSync(join(root, "featured.json"), JSON.stringify(featured)); - return root; -} +import { broken, catalog, good, PNG, png } from "./fixture.mjs"; test("a good app reads back with its README", () => { const root = catalog({ files: { "README.md": "# Tally\n\nSee [the source](https://github.com/ada/tally).\n\n![Main](preview0.png)\n" }, featured: ["tally"] }); diff --git a/tests/fixture.mjs b/tests/fixture.mjs new file mode 100644 index 0000000..e50a36e --- /dev/null +++ b/tests/fixture.mjs @@ -0,0 +1,48 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { crc32, deflateSync } from "node:zlib"; + +function chunk(type, data) { + const head = Buffer.alloc(8); + head.writeUInt32BE(data.length); + head.write(type, 4, "latin1"); + const sum = Buffer.alloc(4); + sum.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data]))); + return Buffer.concat([head, data, sum]); +} + +/** A gray PNG `side` pixels square whose data is one pixel, its scanline led by `filter`. */ +export function png(filter, side = 1) { + const header = Buffer.from([0, 0, 0, side, 0, 0, 0, side, 8, 0, 0, 0, 0]); + const image = deflateSync(Buffer.from([filter, 1])); + return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", header), chunk("IDAT", image), chunk("IEND", Buffer.alloc(0))]); +} +export const PNG = png(0); +export const broken = Buffer.from(PNG); +broken[broken.length - 20] ^= 0xff; + +export const good = { + id: "tally", + name: "Tally", + author: "Ada", + description: "Counts things.", + category: "work", + platforms: ["macOS", "Linux"], + website: null, + source: "https://github.com/ada/tally", + publishedAt: "2026-10-04T00:00:00Z", + previews: ["preview0.png"], +}; + +/** A catalog in a temp folder: one app, its files, and featured.json. */ +export function catalog({ app = good, folder = "tally", files = {}, featured = [] } = {}) { + const root = mkdtempSync(join(tmpdir(), "showcase-")); + const dir = join(root, "apps", folder); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "manifest.json"), JSON.stringify(app)); + writeFileSync(join(dir, "preview0.png"), PNG); + for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body); + writeFileSync(join(root, "featured.json"), JSON.stringify(featured)); + return root; +} diff --git a/tests/stars.test.mjs b/tests/stars.test.mjs new file mode 100644 index 0000000..e30efd1 --- /dev/null +++ b/tests/stars.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; +import { refresh } from "../scripts/stars.mjs"; +import { catalog, good } from "./fixture.mjs"; + +const manifest = (root) => JSON.parse(readFileSync(join(root, "apps", "tally", "manifest.json"), "utf8")); + +test("a new count writes stars and the time", async () => { + const root = catalog(); + const asked = []; + const changed = await refresh(root, async (repo) => (asked.push(repo), 42), "2026-10-05T00:00:00.000Z"); + assert.deepEqual(asked, [{ owner: "ada", repo: "tally" }]); + assert.deepEqual(changed, ["tally"]); + assert.equal(manifest(root).stars, 42); + assert.equal(manifest(root).starsUpdatedAt, "2026-10-05T00:00:00.000Z"); +}); + +test("the same count keeps the time", async () => { + const root = catalog({ app: { ...good, stars: 42, starsUpdatedAt: "2026-10-01T00:00:00Z" } }); + assert.deepEqual(await refresh(root, async () => 42, "2026-10-05T00:00:00.000Z"), []); + assert.equal(manifest(root).starsUpdatedAt, "2026-10-01T00:00:00Z"); +}); + +test("an app off GitHub is not asked", async () => { + const root = catalog({ app: { ...good, source: null, website: "https://tally.app/" } }); + assert.deepEqual(await refresh(root, async () => assert.fail("asked"), "2026-10-05T00:00:00.000Z"), []); +}); + +test("a bad count fails", async () => { + await assert.rejects(refresh(catalog(), async () => undefined, "2026-10-05T00:00:00.000Z"), /GitHub gave undefined stars/); +});