2026-09-25 14:00:22 -04:00
|
|
|
#!/usr/bin/env bun
|
|
|
|
|
/**
|
2026-09-30 16:31:15 -04:00
|
|
|
* Publish one GitHub release per finished topic and per finished
|
|
|
|
|
* `Review <N> — <Phase>` issue, versioned by timever
|
2026-09-25 14:00:22 -04:00
|
|
|
* (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC — https://gist.github.com/twolfson/de1b004dd22536b8e668).
|
|
|
|
|
*
|
2026-09-30 16:31:15 -04:00
|
|
|
* "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.
|
2026-09-25 14:00:22 -04:00
|
|
|
*
|
2026-09-30 16:31:15 -04:00
|
|
|
* Each release is a changelog snapshot at that instant: the issue's problems
|
2026-09-30 17:58:06 -04:00
|
|
|
* 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.
|
|
|
|
|
* apps/docs/lib/releases.ts reads these `### Closed …` tables back for the
|
|
|
|
|
* docs `/changelog` timeline, so their row shape is its contract.
|
2026-09-25 14:00:22 -04:00
|
|
|
*
|
|
|
|
|
* 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
|
2026-09-25 14:15:41 -04:00
|
|
|
* (the API has no field for it), so the docs changelog dates each entry from
|
2026-09-30 17:58:06 -04:00
|
|
|
* this timever tag instead (apps/docs/lib/releases.ts)
|
2026-09-25 14:15:41 -04:00
|
|
|
* — the tag format is that contract.
|
2026-09-25 14:00:22 -04:00
|
|
|
*
|
2026-09-30 16:31:15 -04:00
|
|
|
* 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.
|
2026-09-25 14:00:22 -04:00
|
|
|
* 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";
|
2026-09-30 16:23:54 -04:00
|
|
|
import { movedFromTopics, readReviews, type Problem } from "../api/src/reviews.ts";
|
2026-09-25 14:00:22 -04:00
|
|
|
import { github } from "./github.ts";
|
|
|
|
|
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
2026-09-30 16:31:15 -04:00
|
|
|
import { completion, coreSet, labelValue, readProblems } from "./topics.ts";
|
2026-09-25 14:00:22 -04:00
|
|
|
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 (apps/docs/blume.config.ts `deployment.site`); sync publishes `/<num>` 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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 16:31:15 -04:00
|
|
|
// ── finished topics and reviews ──────────────────────────────────
|
2026-09-25 14:00:22 -04:00
|
|
|
|
2026-09-30 16:31:15 -04:00
|
|
|
/** A topic or Review issue, as far as its release reads it. */
|
|
|
|
|
interface Issue {
|
2026-09-25 14:00:22 -04:00
|
|
|
number: number;
|
|
|
|
|
title: string;
|
|
|
|
|
milestone: string | null;
|
|
|
|
|
body: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Narrow one /issues element; the `topic` label filter cannot exclude PRs. */
|
2026-09-30 16:31:15 -04:00
|
|
|
function readTopic(value: unknown): Issue | undefined {
|
2026-09-25 14:00:22 -04:00
|
|
|
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 {
|
2026-09-30 16:31:15 -04:00
|
|
|
kind: "topic" | "review";
|
|
|
|
|
issue: Issue;
|
|
|
|
|
/** Topic: every problem it owns, by number. Review: its sub-issues, in solve order. */
|
2026-09-25 14:00:22 -04:00
|
|
|
problems: Problem[];
|
2026-09-30 16:31:15 -04:00
|
|
|
/** The set whose last close finished it: a topic's core, a review's every problem. */
|
|
|
|
|
required: number[];
|
2026-09-25 14:00:22 -04:00
|
|
|
finishedAt: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 16:31:15 -04:00
|
|
|
// 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.
|
2026-09-25 14:00:22 -04:00
|
|
|
const finished: Finished[] = [];
|
2026-09-30 16:31:15 -04:00
|
|
|
const reviews = await readReviews(gh);
|
|
|
|
|
const moved = movedFromTopics(reviews);
|
2026-09-25 14:00:22 -04:00
|
|
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
|
2026-09-30 16:31:15 -04:00
|
|
|
const issue = readTopic(raw);
|
|
|
|
|
if (!issue) continue;
|
|
|
|
|
const problems = await readProblems(gh, issue.number, moved);
|
2026-09-25 14:00:22 -04:00
|
|
|
const core = coreSet(problems);
|
2026-09-30 16:31:15 -04:00
|
|
|
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,
|
|
|
|
|
});
|
2026-09-25 14:00:22 -04:00
|
|
|
}
|
|
|
|
|
// Oldest first, so a backfill creates releases in campaign order.
|
2026-09-30 16:31:15 -04:00
|
|
|
finished.sort((a, b) => a.finishedAt.localeCompare(b.finishedAt) || a.issue.number - b.issue.number);
|
2026-09-25 14:00:22 -04:00
|
|
|
|
|
|
|
|
// ── existing releases ────────────────────────────────────────────
|
|
|
|
|
|
2026-09-30 16:31:15 -04:00
|
|
|
/** Topic or review issue -> its release. Name and body are kept in sync with the render on every run. */
|
2026-09-25 14:05:52 -04:00
|
|
|
const released = new Map<number, { id: number; tag: string; name: string; body: string }>();
|
2026-09-25 14:00:22 -04:00
|
|
|
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;
|
2026-09-25 14:05:52 -04:00
|
|
|
const name = "name" in raw && typeof raw.name === "string" ? raw.name : "";
|
2026-09-25 14:00:22 -04:00
|
|
|
const m = raw.body.match(/<!-- release-topics: #(\d+) -->/);
|
2026-09-25 14:05:52 -04:00
|
|
|
if (m) released.set(Number(m[1]), { id: raw.id, tag: raw.tag_name, name, body: raw.body });
|
2026-09-25 14:00:22 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── timever ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Timever of an instant. The API reports whole seconds, so SUBTIME is the
|
2026-09-30 16:31:15 -04:00
|
|
|
* tiebreak: two topics or reviews finishing in the same second (one push
|
|
|
|
|
* closing both last problems) get consecutive nanoseconds, in (instant,
|
|
|
|
|
* issue) order.
|
2026-09-25 14:00:22 -04:00
|
|
|
*/
|
2026-09-30 16:31:15 -04:00
|
|
|
const tags = new Map<number, string>(); // issue -> tag
|
2026-09-25 14:00:22 -04:00
|
|
|
{
|
|
|
|
|
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;
|
2026-09-30 16:31:15 -04:00
|
|
|
tags.set(f.issue.number, `${base}.${String(ns).padStart(9, "0")}`);
|
2026-09-25 14:00:22 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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<number, string[]>): string {
|
2026-09-30 16:31:15 -04:00
|
|
|
const { issue, problems } = f;
|
2026-09-25 14:00:22 -04:00
|
|
|
const finishDay = etDate(new Date(f.finishedAt));
|
2026-09-30 17:58:06 -04:00
|
|
|
// Snapshot at the finish instant: a problem closed later was open then, and
|
|
|
|
|
// open problems are not part of the release.
|
2026-09-25 14:00:22 -04:00
|
|
|
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
|
2026-09-25 14:06:09 -04:00
|
|
|
// with one self-contained sentence and keep the fields on one `·` line.
|
2026-09-25 14:00:22 -04:00
|
|
|
const when = ET_WHEN.format(new Date(f.finishedAt));
|
|
|
|
|
const summary =
|
2026-09-30 16:31:15 -04:00
|
|
|
`Finished ${when} ET: all ${f.required.length} ${f.kind === "topic" ? "core " : ""}problems closed, ` +
|
2026-09-25 14:00:22 -04:00
|
|
|
`${onDay.length} ${onDay.length === 1 ? "problem" : "problems"} closed that day.`;
|
|
|
|
|
const fields: string[] = [];
|
2026-09-30 16:31:15 -04:00
|
|
|
const scheduled = bodyField(issue.body, "Scheduled");
|
2026-09-25 14:00:22 -04:00
|
|
|
if (scheduled) fields.push(`**Scheduled:** ${scheduled}`);
|
2026-09-30 16:31:15 -04:00
|
|
|
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");
|
2026-09-25 14:00:22 -04:00
|
|
|
if (buildsOn) fields.push(`**Builds on:** ${buildsOn}`);
|
2026-09-30 16:31:15 -04:00
|
|
|
const load = bodyField(issue.body, "Load");
|
|
|
|
|
if (load) fields.push(`**Load:** ${load}`);
|
2026-09-25 14:00:22 -04:00
|
|
|
// The marker is how a later run finds this release; see the file header.
|
2026-09-30 16:31:15 -04:00
|
|
|
// One marker for both kinds: issue numbers never collide.
|
|
|
|
|
const sections = [`<!-- release-topics: #${issue.number} -->\n${summary}`, fields.join(" · ")];
|
|
|
|
|
sections.push(`### Closed ${ET_DAY.format(new Date(f.finishedAt))} — the day the ${f.kind} finished\n\n${table(onDay)}`);
|
2026-09-25 14:00:22 -04:00
|
|
|
if (earlier.length) sections.push(`### Closed earlier\n\n${table(earlier)}`);
|
|
|
|
|
const notes = NOTES.flatMap((h) => {
|
2026-09-30 16:31:15 -04:00
|
|
|
const text = bodySection(issue.body, h);
|
2026-09-25 14:00:22 -04:00
|
|
|
return text ? [`**${h}.** ${text}`] : [];
|
|
|
|
|
});
|
|
|
|
|
if (notes.length) sections.push(`### Notes\n\n${notes.join("\n\n")}`);
|
|
|
|
|
sections.push(
|
2026-09-30 16:31:15 -04:00
|
|
|
`---\n\n\`${tag}\` is [timever](${TIMEVER}) for the UTC instant the ` +
|
|
|
|
|
`${f.kind === "topic" ? "core set completed" : "last problem closed"}; ` +
|
2026-09-25 14:00:22 -04:00
|
|
|
`it tags ${sha.slice(0, 7)}, the last main commit before it.`,
|
|
|
|
|
);
|
|
|
|
|
return `${sections.join("\n\n")}\n`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── reconcile ────────────────────────────────────────────────────
|
|
|
|
|
|
2026-09-30 16:31:15 -04:00
|
|
|
const latest = finished.at(-1)?.issue.number;
|
2026-09-25 14:00:22 -04:00
|
|
|
const report: string[][] = [];
|
|
|
|
|
const previews: string[] = [];
|
|
|
|
|
let created = 0;
|
|
|
|
|
let updated = 0;
|
|
|
|
|
|
|
|
|
|
for (const f of finished) {
|
2026-09-30 16:31:15 -04:00
|
|
|
const { issue } = f;
|
2026-09-25 14:00:22 -04:00
|
|
|
const finishedEt = etDate(new Date(f.finishedAt));
|
2026-09-30 16:31:15 -04:00
|
|
|
const existing = released.get(issue.number);
|
2026-09-25 14:05:52 -04:00
|
|
|
// 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.
|
2026-09-30 16:31:15 -04:00
|
|
|
const tag = existing?.tag ?? tags.get(issue.number)!;
|
2026-09-25 14:05:52 -04:00
|
|
|
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}`);
|
|
|
|
|
}
|
2026-09-25 14:00:22 -04:00
|
|
|
const sha = existing
|
|
|
|
|
? git("rev-parse", `${tag}^{commit}`)
|
|
|
|
|
: git("rev-list", "-1", "--first-parent", `--before=${f.finishedAt}`, "HEAD");
|
|
|
|
|
if (!sha) {
|
2026-09-30 16:31:15 -04:00
|
|
|
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "ERROR: no commit before finish"]);
|
2026-09-25 14:00:22 -04:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
// LC number -> its first-solve files in the tagged tree. Keyed by file name,
|
|
|
|
|
// like firstSolved(): tags before the bucket split see `work/<Difficulty>/…`,
|
|
|
|
|
// and the work/3 + work/7 re-solves are not the solve that closed the issue.
|
|
|
|
|
const files = new Map<number, string[]>();
|
|
|
|
|
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);
|
2026-09-30 16:31:15 -04:00
|
|
|
const name = issue.title;
|
2026-09-25 14:00:22 -04:00
|
|
|
|
|
|
|
|
if (existing) {
|
2026-09-25 14:05:52 -04:00
|
|
|
if (existing.body === body && existing.name === name) {
|
2026-09-30 16:31:15 -04:00
|
|
|
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "released"]);
|
2026-09-25 14:00:22 -04:00
|
|
|
} else if (DRY) {
|
2026-09-25 14:05:52 -04:00
|
|
|
previews.push(`## ${name} (${tag})\n\n${body}`);
|
2026-09-30 16:31:15 -04:00
|
|
|
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "would update"]);
|
2026-09-25 14:00:22 -04:00
|
|
|
} else {
|
|
|
|
|
await gh.api(`/repos/${gh.repo}/releases/${existing.id}`, {
|
|
|
|
|
method: "PATCH",
|
2026-09-25 14:05:52 -04:00
|
|
|
body: JSON.stringify({ name, body }),
|
2026-09-25 14:00:22 -04:00
|
|
|
});
|
2026-09-30 16:31:15 -04:00
|
|
|
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "updated"]);
|
2026-09-25 14:00:22 -04:00
|
|
|
updated++;
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (DRY) {
|
2026-09-25 14:05:52 -04:00
|
|
|
previews.push(`## ${name} (${tag})\n\n${body}`);
|
2026-09-30 16:31:15 -04:00
|
|
|
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "would release"]);
|
2026-09-25 14:00:22 -04:00
|
|
|
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(
|
2026-09-30 16:31:15 -04:00
|
|
|
[
|
|
|
|
|
"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(", ")}`,
|
|
|
|
|
],
|
2026-09-25 14:00:22 -04:00
|
|
|
{ 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,
|
2026-09-25 14:05:52 -04:00
|
|
|
name,
|
2026-09-25 14:00:22 -04:00
|
|
|
body,
|
2026-09-30 16:31:15 -04:00
|
|
|
make_latest: issue.number === latest ? "true" : "false",
|
2026-09-25 14:00:22 -04:00
|
|
|
}),
|
|
|
|
|
});
|
2026-09-30 16:31:15 -04:00
|
|
|
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "released now"]);
|
2026-09-25 14:00:22 -04:00
|
|
|
created++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── report ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
if (previews.length) console.log(`${previews.join("\n")}\n`);
|
2026-09-30 16:31:15 -04:00
|
|
|
const rows = [["issue", "title", "finished (ET)", "tag", "status"], ...report];
|
2026-09-25 14:00:22 -04:00
|
|
|
printTable(rows);
|
|
|
|
|
const would = (status: string) => report.filter((r) => r[4] === status).length;
|
|
|
|
|
console.log(
|
2026-09-30 16:31:15 -04:00
|
|
|
`\n${finished.length} finished topics and reviews · ` +
|
2026-09-25 14:00:22 -04:00
|
|
|
(DRY
|
|
|
|
|
? `would release ${would("would release")}, update ${would("would update")}`
|
|
|
|
|
: `released ${created}, updated ${updated}`),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await writeStepSummary(
|
|
|
|
|
`### release-topics${DRY ? " (dry run)" : ""}\n\n` +
|
2026-09-30 16:31:15 -04:00
|
|
|
`${finished.length} finished topics and reviews, ${released.size} already released.\n\n` +
|
|
|
|
|
(report.length ? `${markdownTable(rows)}\n` : "Nothing finished.\n") +
|
2026-09-25 14:00:22 -04:00
|
|
|
(previews.length ? `\n${previews.join("\n")}` : ""),
|
|
|
|
|
);
|