CI: validate every push and pull request; stars refreshed on main and weekly
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6eef671f14
commit
912bc328cf
8 files changed
+194
-46
No files matched your search
@@ -0,0 +1,2 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
+1
-45
@@ -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\n" }, featured: ["tally"] });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in new issue
Block a user