#!/usr/bin/env node /** * Local-dev seed for the checks pages: a few more commits on the demo * repository, and the check records a runner would have published about them. * The account runs as its own runner, which is what the config record's * `runner` field names. A reader resolves that DID and reads its checks, so * one account playing both parts exercises the same path as two. * * The runs are shaped to cover what the pages have to render: several * workflows on one commit, a workflow re-run, a failure beside a success, and * a run still going. * * Run `node scripts/dev-git-demo.mjs` first; this pushes onto the repository * that leaves behind. See `just dev-git`, which runs all three. * * Usage: node scripts/dev-git-checks.mjs [repo-name] * Env: PDS_DEV_URL (default http://localhost:2471) * PDS_DEV_PASSWORD (default test-password) * PDS_DEV_PLC_URL (default http://localhost:2582) * PDS_DEV_WORK_DIR (default .dev-pds/work) */ import { spawnSync } from 'node:child_process'; import { appendFileSync } from 'node:fs'; import { resolve } from 'node:path'; const BASE = process.env.PDS_DEV_URL || 'http://localhost:2471'; const PASSWORD = process.env.PDS_DEV_PASSWORD || 'test-password'; const PLC_URL = process.env.PDS_DEV_PLC_URL || 'http://localhost:2582'; const WORK = resolve(process.env.PDS_DEV_WORK_DIR || '.dev-pds/work'); const NAME = process.argv[2] || 'demo-app'; const CHECK_COLLECTION = 'dev.pdsjs.git.check'; const LATEST_CHECK_COLLECTION = 'dev.pdsjs.git.latestCheck'; const REF = 'refs/heads/main'; /** @param {string} command @param {string[]} args @param {object} [options] */ function run(command, args, options = {}) { const result = spawnSync(command, args, { cwd: `${WORK}/${NAME}`, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', ...options, env: { ...process.env, ...(options.env ?? {}) }, }); if (result.status !== 0) { throw new Error( `${command} ${args.join(' ')} failed:\n${result.stderr || result.stdout}`, ); } return result.stdout?.trim() ?? ''; } const did = (await (await fetch(`${BASE}/.well-known/atproto-did`)).text()) .trim() .replace(/^"|"$/g, ''); const session = await ( await fetch(`${BASE}/xrpc/com.atproto.server.createSession`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: did, password: PASSWORD }), }) ).json(); const AUTH = { Authorization: `Bearer ${session.accessJwt}` }; /** @param {string} path @param {unknown} body */ async function call(path, body) { const res = await fetch(`${BASE}/xrpc/${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...AUTH }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`${path} answered ${res.status}`); return res.json(); } // ---- 1. a history worth listing ------------------------------------------ const COMMITS = [ ['README.md', '\nBuilt by its own runner.\n', 'document the runner'], ['index.js', 'export const version = () => "1.0.1";\n', 'add version()'], [ 'index.js', 'export const shout = (who) => `HELLO, ${who}`;\n', 'add shout()', ], ]; for (const [file, body, message] of COMMITS) { appendFileSync(`${WORK}/${NAME}/${file}`, body); run('git', ['add', '-A']); run('git', [ '-c', 'user.email=alice@localhost', '-c', 'user.name=Alice', 'commit', '-qm', message, ]); } run('git', ['push', '-q', `atproto://${did}/${NAME}`, 'main'], { env: { PATH: `${WORK}/bin:${process.env.PATH}`, ATPROTO_GIT_IDENTIFIER: did, ATPROTO_GIT_PASSWORD: PASSWORD, ATPROTO_GIT_PLC_URL: PLC_URL, ATPROTO_GIT_SERVICE: BASE, }, }); // Newest first, which is the order the runs below are written in. const shas = run('git', ['log', '-4', '--format=%H']).split('\n'); console.log(`Pushed ${COMMITS.length} more commits to ${NAME}`); // ---- 2. what the runner would have published ------------------------------ const repoRecord = await ( await fetch( `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=dev.pdsjs.git.repo&rkey=${NAME}`, ) ).json(); const subject = { uri: repoRecord.uri, cid: repoRecord.cid }; /** A step's line in the log, the way a shell runner writes one. */ const logFor = (steps) => steps .map(({ name, exitCode }) => exitCode === 0 ? `$ ${name}\nran ${name} in the checkout\n${name}: ok\n` : `$ ${name}\nran ${name} in the checkout\n${name}: FAILED\nexit status ${exitCode}\n`, ) .join('\n'); /** @param {string} text */ async function uploadLog(text) { const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { method: 'POST', headers: { 'Content-Type': 'text/plain', ...AUTH }, body: text, }); if (!res.ok) throw new Error(`uploadBlob answered ${res.status}`); return (await res.json()).blob; } const MINUTE = 60_000; let clock = Date.now() - 90 * MINUTE; /** * One run, written the way the runner writes one. * @param {{sha: string, workflow: string, steps: {name: string, exitCode: number, durationMs: number}[], running?: boolean}} spec */ async function publish({ sha, workflow, steps, running }) { const startedAt = new Date(clock).toISOString(); const took = steps.reduce((total, step) => total + step.durationMs, 0); clock += took + MINUTE; const failed = steps.find((step) => step.exitCode !== 0); const status = running ? 'running' : failed ? 'failure' : 'success'; const record = { $type: CHECK_COLLECTION, subject, ref: REF, sha, workflow, status, steps, startedAt, ...(running ? {} : { finishedAt: new Date(Date.parse(startedAt) + took).toISOString(), logs: await uploadLog(logFor(steps)), }), }; const { uri, cid } = await call('com.atproto.repo.createRecord', { repo: did, collection: CHECK_COLLECTION, record, }); return { workflow, subject, check: { uri, cid }, sha, status, startedAt }; } const step = (name, exitCode, durationMs) => ({ name, exitCode, durationMs }); const install = step('pnpm install --frozen-lockfile', 0, 21_400); // Oldest commit first, so the runs read in the order they happened. const [head, second, third, first] = shas; const written = []; for (const spec of [ { sha: first, workflow: 'ci', steps: [install, step('npm test', 0, 46_800)] }, { sha: third, workflow: 'ci', steps: [install, step('npm test', 0, 51_200)] }, { sha: third, workflow: 'lint', steps: [install, step('biome check .', 0, 3_900)], }, { sha: second, workflow: 'ci', steps: [install, step('npm test', 1, 38_500)], }, // The same workflow again, which is what a re-run leaves behind. { sha: second, workflow: 'ci', steps: [install, step('npm test', 0, 44_100)], }, { sha: second, workflow: 'lint', steps: [install, step('biome check .', 0, 4_200)], }, { sha: second, workflow: 'publish', steps: [install, step('npm publish', 0, 12_600)], }, { sha: head, workflow: 'lint', steps: [install, step('biome check .', 0, 4_050)], }, { sha: head, workflow: 'ci', steps: [ install, step('npm test', 1, 62_300), step('npm run typecheck', 0, 9_100), ], }, { sha: head, workflow: 'publish', steps: [install, step('npm publish', 0, 0)], running: true, }, ]) { written.push(await publish(spec)); } console.log(`Published ${written.length} check records as ${did}`); // ---- 3. the record a reader asks for by name ------------------------------ /** The newest entry per workflow, which is what this record holds. */ const latest = new Map(); for (const entry of written) latest.set(entry.workflow, entry); const RKEY_SAFE = /[^A-Za-z0-9._~:-]/g; await call('com.atproto.repo.putRecord', { repo: did, collection: LATEST_CHECK_COLLECTION, rkey: `${did}:${NAME}:${REF}`.replace(RKEY_SAFE, '_'), record: { $type: LATEST_CHECK_COLLECTION, ref: REF, checks: [...latest.values()], }, }); // ---- 4. the repository names its runner ----------------------------------- const config = await ( await fetch( `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=dev.pdsjs.git.config&rkey=${NAME}`, ) ).json(); await call('com.atproto.repo.putRecord', { repo: did, collection: 'dev.pdsjs.git.config', rkey: NAME, record: { ...config.value, runner: did }, }); console.log(`${NAME} names ${did} as its runner`); // ---- 5. a palette the account publishes ---------------------------------- // Proves the theme record path: the page ships four palettes and reads any // the account publishes beside them. await call('com.atproto.repo.putRecord', { repo: did, collection: 'dev.pdsjs.app.theme', rkey: 'ember', record: { $type: 'dev.pdsjs.app.theme', name: 'Ember', scheme: 'dark', colors: { background: '#160f0c', card: '#1d1512', muted: '#1d1512', accent: '#2a1e19', border: '#33241d', input: '#4a3529', foreground: '#f4e7de', 'muted-foreground': '#b39b8c', faint: '#7d6357', key: '#e8873c', ring: '#e8873c', destructive: '#e2564a', success: '#8fb15c', 'diff-add': '#1d2416', 'diff-del': '#2a1614', 'code-keyword': '#e8873c', 'code-string': '#8fb15c', 'code-number': '#e0b64a', 'code-title': '#f4e7de', 'code-comment': '#7d6357', }, createdAt: new Date().toISOString(), }, }); console.log(`${did} publishes the Ember palette`); // ---- 6. an identity the account claims ----------------------------------- // The commits above are co-authored, and the tool that co-wrote them holds no // atproto account. The account says what to call it, which is what lets a // facepile name it. A real one would give it a face too, as an avatar blob. await call('com.atproto.repo.putRecord', { repo: did, collection: 'dev.pdsjs.git.identity', rkey: 'self', record: { $type: 'dev.pdsjs.git.identity', idents: [ { email: 'alice@localhost', did }, { email: 'noreply@anthropic.com', label: 'Claude' }, ], createdAt: new Date().toISOString(), }, }); console.log('Claimed the identities behind the commits');