#!/usr/bin/env bun /** * Publish one GitHub release per finished topic and per finished * `Review — ` issue, versioned by timever * (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC — https://gist.github.com/twolfson/de1b004dd22536b8e668). * * "Finished" is topics.ts's rule — a topic when every core problem is closed, * a review when every one of its sub-issues is — the same one close-topics.ts * closes the issue on, and the version is the instant the LAST required * problem closed. Keying off the sub-issues rather than the topic/review issue * lets this run as the tail of Close Solved: the push that closes the final * required problem releases it in the same run, without waiting for * close-topics to chain after it. The issue itself closes seconds later, on * the same day. * * Each release is a changelog snapshot at that instant: the issue's problems * that had closed by then (a topic's sub-issues plus those since re-parented * into Review issues, or a review's sub-issues in solve order — see * topics.ts), grouped as closed on the finish day (ET) and closed earlier, * each with its set, difficulty, and the solution files as they stood in the * tagged commit — the last main commit before the finish. Problems still open * at the finish are left out: the release records what finished, and an open * optional problem is tracked by its issue (or its Review day), not here. * The docs site (prdlk/cs `lib/releases.ts`) reads these `### Closed …` * tables back for its `/changelog` timeline, so their row shape is its contract. * * The tag is annotated and backdated (tagger date = finish), so * `git tag --sort=taggerdate` reads as the campaign's history even for tags * minted by a backfill. The release's own published date cannot be backdated * (the API has no field for it), so the docs changelog dates each entry from * this timever tag instead (prdlk/cs `lib/releases.ts`) * — the tag format is that contract. * * Reconciles instead of reacting: every finished topic or review without a * release gets one, so a first run backfills the whole campaign; an existing * release keeps its tag and commit and only has its body rewritten when * render() would say something else. Re-runs with nothing new are no-ops. * A release is found by the marker comment in its body, never by tag name, so * reopening and re-closing a core problem can never mint a second release. * A tag that exists without its release (a run that died between the two) is * reused, not re-created. * * bun apps/cli/release-topics.ts # tag, push, release, fix bodies * bun apps/cli/release-topics.ts --dry-run # print the would-be releases * * Auth: GH_TOKEN / GITHUB_TOKEN, else `gh auth token` (needs contents: write). * Git: a clone with full history, an `origin` it may push tags to, and a * committer identity (the tagger). */ import { etDate } from "../api/src/srs.ts"; import { movedFromTopics, readReviews, type Problem } from "../api/src/reviews.ts"; import { github } from "./github.ts"; import { markdownTable, printTable, writeStepSummary } from "./report.ts"; import { completion, coreSet, labelValue, readProblems } from "./topics.ts"; import { ROOT, parseWorkPath } from "./work.ts"; const DRY = process.argv.includes("--dry-run") || process.env.DRY_RUN === "1" || process.env.DRY_RUN === "true"; /** The docs site (prdlk/cs `blume.config.ts` `deployment.site`); it publishes sync's `/` short links. */ const DOCS_SITE = "https://lc.prd.lk"; const TIMEVER = "https://gist.github.com/twolfson/de1b004dd22536b8e668"; const gh = await github(); function git(...args: string[]): string { const run = Bun.spawnSync(["git", ...args], { cwd: ROOT }); if (run.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${run.stderr.toString().trim()}`); return run.stdout.toString().trim(); } // ── finished topics and reviews ────────────────────────────────── /** A topic or Review issue, as far as its release reads it. */ interface Issue { number: number; title: string; milestone: string | null; body: string; } /** Narrow one /issues element; the `topic` label filter cannot exclude PRs. */ function readTopic(value: unknown): Issue | undefined { if (!value || typeof value !== "object") return; if ("pull_request" in value) return; if (!("number" in value) || typeof value.number !== "number") return; if (!("title" in value) || typeof value.title !== "string") return; const milestone = "milestone" in value && value.milestone && typeof value.milestone === "object" && "title" in value.milestone && typeof value.milestone.title === "string" ? value.milestone.title : null; const body = "body" in value && typeof value.body === "string" ? value.body : ""; return { number: value.number, title: value.title, milestone, body }; } interface Finished { kind: "topic" | "review"; issue: Issue; /** Topic: every problem it owns, by number. Review: its sub-issues, in solve order. */ problems: Problem[]; /** The set whose last close finished it: a topic's core, a review's every problem. */ required: number[]; finishedAt: string; } // Closed and open issues alike: the rules read the sub-issues, so a topic or // review finished moments ago (close-topics not yet run) releases now. const finished: Finished[] = []; const reviews = await readReviews(gh); const moved = movedFromTopics(reviews); for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) { const issue = readTopic(raw); if (!issue) continue; const problems = await readProblems(gh, issue.number, moved); const core = coreSet(problems); if (core.finishedAt) finished.push({ kind: "topic", issue, problems, required: core.all, finishedAt: core.finishedAt }); } for (const review of reviews) { const set = completion(review.problems); if (!set.finishedAt) continue; finished.push({ kind: "review", // The review's phase is its body's `**Phase:**` line; its milestone is the // shared "Review — Transfer & Mocks" and says nothing per issue. issue: { number: review.number, title: review.title, milestone: null, body: review.body }, problems: review.problems, required: set.all, finishedAt: set.finishedAt, }); } // Oldest first, so a backfill creates releases in campaign order. finished.sort((a, b) => a.finishedAt.localeCompare(b.finishedAt) || a.issue.number - b.issue.number); // ── existing releases ──────────────────────────────────────────── /** Topic or review issue -> its release. Name and body are kept in sync with the render on every run. */ const released = new Map(); for await (const raw of gh.list(`/repos/${gh.repo}/releases`)) { if (!raw || typeof raw !== "object") continue; if (!("id" in raw) || typeof raw.id !== "number") continue; if (!("tag_name" in raw) || typeof raw.tag_name !== "string") continue; if (!("body" in raw) || typeof raw.body !== "string") continue; const name = "name" in raw && typeof raw.name === "string" ? raw.name : ""; const m = raw.body.match(//); if (m) released.set(Number(m[1]), { id: raw.id, tag: raw.tag_name, name, body: raw.body }); } // ── timever ────────────────────────────────────────────────────── /** * Timever of an instant. The API reports whole seconds, so SUBTIME is the * tiebreak: two topics or reviews finishing in the same second (one push * closing both last problems) get consecutive nanoseconds, in (instant, * issue) order. */ const tags = new Map(); // issue -> tag { let prev = ""; let ns = 0; for (const f of finished) { const iso = new Date(f.finishedAt).toISOString(); // 2026-09-24T18:36:38.000Z const base = `${iso.slice(0, 10).replaceAll("-", "")}.${iso.slice(11, 19).replaceAll(":", "")}`; ns = base === prev ? ns + 1 : Number(iso.slice(20, 23)) * 1_000_000; prev = base; tags.set(f.issue.number, `${base}.${String(ns).padStart(9, "0")}`); } } // ── release body ───────────────────────────────────────────────── const ET_WHEN = new Intl.DateTimeFormat("en-US", { timeZone: "America/New_York", weekday: "short", month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", }); const ET_DAY = new Intl.DateTimeFormat("en-US", { timeZone: "America/New_York", month: "short", day: "numeric", }); /** Topic body sections worth carrying into the changelog (skipped while still `TODO`). */ const NOTES = ["Trigger", "Invariant", "Trap"] as const; function bodyField(body: string, field: string): string | undefined { return body.match(new RegExp(`^\\*\\*${field}:\\*\\*\\s*(.+)$`, "m"))?.[1]?.trim(); } function bodySection(body: string, heading: string): string | undefined { const m = body.match(new RegExp(`^### ${heading}\\n([\\s\\S]*?)(?=^### |(?![\\s\\S]))`, "m")); const text = m?.[1]?.trim(); return text && text !== "TODO" ? text : undefined; } function render(f: Finished, tag: string, sha: string, files: Map): string { const { issue, problems } = f; const finishDay = etDate(new Date(f.finishedAt)); // Snapshot at the finish instant: a problem closed later was open then, and // open problems are not part of the release. const closedBy = (p: Problem) => p.closedAt !== null && p.closedAt <= f.finishedAt; const onDay = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) === finishDay); const earlier = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) !== finishDay); const row = (p: Problem): string[] => { const lc = Number(p.title.match(/^LC (\d+)/)?.[1]); const code = closedBy(p) ? (files.get(lc) ?? []).map( (path) => `[${path.slice(path.lastIndexOf(".") + 1)}](https://github.com/${gh.repo}/blob/${tag}/${encodeURI(path)})`, ) : []; return [ `#${p.number}`, p.title, labelValue(p, "set:") ?? "—", labelValue(p, "diff:") ?? "—", closedBy(p) ? ET_DAY.format(new Date(p.closedAt!)) : "—", code.length ? [...code, `[docs](${DOCS_SITE}/${lc})`].join(" · ") : "—", ]; }; const table = (list: Problem[]) => markdownTable([["Issue", "Problem", "Set", "Difficulty", "Closed (ET)", "Solution"], ...list.map(row)]); // Blume's changelog derives each page's meta description from the opening // prose, stripping markdown and line breaks without adding a space — so lead // with one self-contained sentence and keep the fields on one `·` line. const when = ET_WHEN.format(new Date(f.finishedAt)); const summary = `Finished ${when} ET: all ${f.required.length} ${f.kind === "topic" ? "core " : ""}problems closed, ` + `${onDay.length} ${onDay.length === 1 ? "problem" : "problems"} closed that day.`; const fields: string[] = []; const scheduled = bodyField(issue.body, "Scheduled"); if (scheduled) fields.push(`**Scheduled:** ${scheduled}`); const phase = f.kind === "topic" ? issue.milestone : bodyField(issue.body, "Phase"); if (phase) fields.push(`**Phase:** ${phase}`); fields.push(`**${f.kind === "topic" ? "Topic" : "Review"}:** #${issue.number}`); const buildsOn = bodyField(issue.body, "Builds on"); if (buildsOn) fields.push(`**Builds on:** ${buildsOn}`); const load = bodyField(issue.body, "Load"); if (load) fields.push(`**Load:** ${load}`); // The marker is how a later run finds this release; see the file header. // One marker for both kinds: issue numbers never collide. const sections = [`\n${summary}`, fields.join(" · ")]; sections.push(`### Closed ${ET_DAY.format(new Date(f.finishedAt))} — the day the ${f.kind} finished\n\n${table(onDay)}`); if (earlier.length) sections.push(`### Closed earlier\n\n${table(earlier)}`); const notes = NOTES.flatMap((h) => { const text = bodySection(issue.body, h); return text ? [`**${h}.** ${text}`] : []; }); if (notes.length) sections.push(`### Notes\n\n${notes.join("\n\n")}`); sections.push( `---\n\n\`${tag}\` is [timever](${TIMEVER}) for the UTC instant the ` + `${f.kind === "topic" ? "core set completed" : "last problem closed"}; ` + `it tags ${sha.slice(0, 7)}, the last main commit before it.`, ); return `${sections.join("\n\n")}\n`; } // ── reconcile ──────────────────────────────────────────────────── const latest = finished.at(-1)?.issue.number; const report: string[][] = []; const previews: string[] = []; let created = 0; let updated = 0; for (const f of finished) { const { issue } = f; const finishedEt = etDate(new Date(f.finishedAt)); const existing = released.get(issue.number); // An existing release keeps its tag and commit; only its name and body are // reconciled. Its tag may postdate this clone (released by another run), so // fetch it when missing — read-only, so a dry run does it too. const tag = existing?.tag ?? tags.get(issue.number)!; if (existing && Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT }).exitCode !== 0) { git("fetch", "-q", "origin", `refs/tags/${tag}:refs/tags/${tag}`); } const sha = existing ? git("rev-parse", `${tag}^{commit}`) : git("rev-list", "-1", "--first-parent", `--before=${f.finishedAt}`, "HEAD"); if (!sha) { report.push([`#${issue.number}`, issue.title, finishedEt, tag, "ERROR: no commit before finish"]); continue; } // LC number -> its first-solve files in the tagged tree. Keyed by file name, // like firstSolved(): tags before the bucket split see `work//…`, // and the work/3 + work/7 re-solves are not the solve that closed the issue. const files = new Map(); for (const path of git("ls-tree", "-r", "--name-only", sha, "--", "work").split("\n")) { const bucket = parseWorkPath(path.slice("work/".length))?.bucket; const lc = path.match(/\/(\d+)\.[^/]+\.(?:js|py)$/)?.[1]; if (!lc || (bucket && bucket !== 1)) continue; files.set(Number(lc), [...(files.get(Number(lc)) ?? []), path]); } const body = render(f, tag, sha, files); const name = issue.title; if (existing) { if (existing.body === body && existing.name === name) { report.push([`#${issue.number}`, issue.title, finishedEt, tag, "released"]); } else if (DRY) { previews.push(`## ${name} (${tag})\n\n${body}`); report.push([`#${issue.number}`, issue.title, finishedEt, tag, "would update"]); } else { await gh.api(`/repos/${gh.repo}/releases/${existing.id}`, { method: "PATCH", body: JSON.stringify({ name, body }), }); report.push([`#${issue.number}`, issue.title, finishedEt, tag, "updated"]); updated++; } continue; } if (DRY) { previews.push(`## ${name} (${tag})\n\n${body}`); report.push([`#${issue.number}`, issue.title, finishedEt, tag, "would release"]); continue; } const remote = git("ls-remote", "--tags", "origin", `refs/tags/${tag}`); if (!remote) { const local = Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT }); if (local.exitCode !== 0) { const tagged = Bun.spawnSync( [ "git", "tag", "-a", tag, sha, "-m", `${issue.title} (#${issue.number})\n\n` + `${f.kind === "topic" ? "Core set complete" : "All problems closed"}: ${f.required.map((n) => `#${n}`).join(", ")}`, ], { cwd: ROOT, env: { ...process.env, GIT_COMMITTER_DATE: f.finishedAt } }, ); if (tagged.exitCode !== 0) throw new Error(`git tag ${tag}: ${tagged.stderr.toString().trim()}`); } git("push", "origin", `refs/tags/${tag}`); } await gh.api(`/repos/${gh.repo}/releases`, { method: "POST", body: JSON.stringify({ tag_name: tag, name, body, make_latest: issue.number === latest ? "true" : "false", }), }); report.push([`#${issue.number}`, issue.title, finishedEt, tag, "released now"]); created++; } // ── report ─────────────────────────────────────────────────────── if (previews.length) console.log(`${previews.join("\n")}\n`); const rows = [["issue", "title", "finished (ET)", "tag", "status"], ...report]; printTable(rows); const would = (status: string) => report.filter((r) => r[4] === status).length; console.log( `\n${finished.length} finished topics and reviews · ` + (DRY ? `would release ${would("would release")}, update ${would("would update")}` : `released ${created}, updated ${updated}`), ); await writeStepSummary( `### release-topics${DRY ? " (dry run)" : ""}\n\n` + `${finished.length} finished topics and reviews, ${released.size} already released.\n\n` + (report.length ? `${markdownTable(rows)}\n` : "Nothing finished.\n") + (previews.length ? `\n${previews.join("\n")}` : ""), );