#!/usr/bin/env node /** * Tags a release and records it on Forgejo, with notes built from the commits. * * ## Why this exists * * `release.sh` published an image and stopped. Forty-eight versions were cut * that way, and the only record any of them existed was a registry entry and a * `chore(release):` commit — so answering "what changed between these two * images" meant reading the log by hand. This writes that answer down once, at * the moment the facts are still known. * * ## The boundary is the previous release commit, not a tag * * There were no tags at all when this was written, so "since the last tag" * had nothing to start from. Every release leaves a `chore(release): vX.Y.Z` * commit, and the one before this release is exactly the range wanted — which * works identically before and after tags exist, and needs no state file. * * ## Why the subjects are scrubbed before they are published * * The body goes to a **public** repository and is assembled from commit * subjects nobody wrote with that in mind. `redact` is the same scrubber the * daily standup uses, imported rather than reimplemented — a credential-shaped * string in a subject would otherwise be published verbatim, and nobody would * notice. * * `noDashes` is deliberately *not* applied, though `cleanQuoted` bundles it: * that is a Discord house-style rule, and the subjects in this repository use * em dashes on purpose. * * ## What it refuses to do * * Move a tag. `release.sh` already refuses to overwrite a published image tag — * "a published tag is not moved" — and a git tag pointing at a different commit * is the same fact about a different artifact. Re-running after a partial * failure is expected and safe; rewriting history is not. * * scripts/release-notes.mjs v0.54.31 * scripts/release-notes.mjs v0.54.31 --dry-run */ import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; /** * The scrubber and the escaper, carried rather than imported. * * This file used to `import { escapeMarkdown, redact } from "../notices/compose.mjs"`, * which is fine here and fatal the moment the script is copied: the Template * has no `notices/`, and a release script that cannot resolve its own imports * is one nobody runs. `compose.mjs` earns its portability by having no imports * at all; this now does the same. * * `REDACTIONS` is therefore a third copy of the list in `src/lib/log.ts` and * `notices/compose.mjs`, and a third copy of a credential scrubber is exactly * the kind of thing that rots quietly. `tests/release-notes.test.ts` extracts * this block and asserts it is identical to the one the worker holds — the same * device `tests/notice-message.test.ts` already uses for the second copy. Two * lists that drift are the danger; lists a test refuses to let drift are a copy. * * This matters more here than almost anywhere: the body is assembled from * commit subjects nobody wrote expecting publication, and it is published to a * repository that is public. */ const REDACTIONS = [ [/\/\/[^/@\s:]+:[^/@\s]+@/g, "//@"], [/([?&](?:token|key|secret|password|access_token|api_key)=)[^&\s]+/gi, "$1"], [/pllc_[a-z]+_[0-9a-f]{8,}/g, ""], [/v2:[0-9a-f]{8}:[^\s"]+/g, ""], [/\b(Bearer|token|Basic)\s+[A-Za-z0-9._~+/=-]{12,}/gi, "$1 "], [/\b[0-9a-f]{40,}\b/gi, ""], ]; function redact(text) { return REDACTIONS.reduce((out, [pattern, replacement]) => out.replace(pattern, replacement), text); } /** * Newlines flattened, markup escaped — `compose.mjs`'s reasoning, unchanged. * * `#`, `>` and `-` are markup only at the start of a line, so escaping them * inline would turn `My-Project` into `My\-Project` in every message. * Flattening to one line is what actually removes the risk. `[` and `(` are the * pair that matter most: they are how a subject becomes a working link. */ function escapeMarkdown(text) { return text.replace(/\r?\n/g, " ").replace(/([\\`*_~|[\]()])/g, "\\$1"); } /** * The section each commit type gets, in the order they are printed. * * The keys are `.githooks/commit-msg`'s closed type list, which is the whole * reason this grouping is possible: every commit is already labelled with the * section it belongs in, enforced at the moment it is written rather than * inferred afterwards. A type added there and not here lands in `other`, which * is visible rather than silent. */ export const SECTIONS = [ ["feat", "🚀 Features"], ["fix", "🐛 Fixes"], ["security", "🔒 Security"], ["perf", "⚡ Performance"], ["ui", "🎨 Interface"], ["refactor", "🧹 Refactoring"], ["docs", "📚 Documentation"], ["test", "🧪 Tests"], ["chore", "🔧 Tooling"], ["other", "📦 Other"], ]; /** `type(scope)!: subject`, the shape `.githooks/commit-msg` enforces. */ const CONVENTIONAL = /^([a-z]+)(?:\([a-z0-9._-]+\))?!?: (.+)$/; /** * Commits grouped by the type they declared. * * Takes `sha subject` lines. Two rules earn their place: * * `chore(release)` commits are dropped — they are the boundaries of the range, * not content in it, and listing "chore(release): v0.54.30" under Tooling would * be the release describing itself. * * Everything else that does not parse goes to `other` rather than being * skipped. `commit-msg` exempts `Merge`, `Revert`, `fixup!`, `squash!` and * `amend!`, so unparseable subjects genuinely occur — and a commit vanishing * from the notes is the failure this file exists to prevent, not a tidiness * problem. */ export function groupCommits(lines) { const groups = new Map(SECTIONS.map(([key]) => [key, []])); for (const line of lines) { const trimmed = String(line ?? "").trim(); if (!trimmed) continue; const split = trimmed.indexOf(" "); const sha = split === -1 ? trimmed : trimmed.slice(0, split); const subject = split === -1 ? "" : trimmed.slice(split + 1); if (/^chore\(release\)/.test(subject)) continue; const match = CONVENTIONAL.exec(subject); const key = match && groups.has(match[1]) ? match[1] : "other"; groups.get(key).push({ sha, subject: match ? match[2] : subject }); } return groups; } /** * The release body. * * Every subject is scrubbed then escaped, in that order: `redact` removes what * must not be published, `escapeMarkdown` stops what remains from becoming * markup. `escapeMarkdown` also flattens newlines, so a subject cannot start a * line it is not allowed to start. * * An empty range still produces a body. A release with no commits behind it is * a real thing — a re-cut, a version bump — and saying so is better than an * empty section or a missing record. */ export function renderBody({ tag, image, digest, groups, signoff, milestones = [], deployNote = DEFAULTS.deployNote, }) { const clean = (text) => escapeMarkdown(redact(String(text ?? ""))); const lines = [`Welcome to **${clean(tag)}**.`, ""]; const listed = SECTIONS.filter(([key]) => (groups.get(key) ?? []).length > 0); if (listed.length === 0) { lines.push("No commits landed between this release and the one before it.", ""); } else { lines.push("## What's Changed", ""); for (const [key, heading] of listed) { lines.push(`### ${heading}`); for (const commit of groups.get(key)) { lines.push(`- ${clean(commit.subject)} (\`${clean(commit.sha)}\`)`); } lines.push(""); } } // Named before the image, because "which batch shipped" is the question a // release closes and the image tag is the detail underneath it. if (milestones.length > 0) { lines.push("## Milestones completed", ""); for (const title of milestones) lines.push(`- ${clean(title)}`); lines.push(""); } lines.push("## The image", ""); lines.push(` ${image}:${tag}`); // Absent rather than invented. A digest is what makes a tag checkable, and a // release claiming one it did not read would be worse than one that stays // quiet about it. if (digest) lines.push(` ${digest}`); lines.push(""); // Publishing and deploying are separate decisions, and saying so is the one // sentence a reader of these notes most needs. *How* they are separate is // each project's own business, which is why it is a setting rather than a // string: naming one project's stack in another's release notes is exactly // the hard-coding this directory refuses. // // Set RELEASE_DEPLOY_NOTE to describe how this project actually deploys, or // to an empty string where publishing and deploying are one act. A note that // describes a workflow the project has moved past is worse than none, // because it is read as current. if (deployNote) { lines.push(...deployNote.split("\n"), ""); } // The sign-off marks a batch landing, not every patch. // // Forty-eight releases were cut in five days before this existed. An image on // every one of them is wallpaper; on the release that finishes a batch it // means something. The condition is the same fact that drives the version // bump, so the two cannot disagree: a release either completed a milestone or // it did not. // // The tag rides along as a query string, and it is not decoration. // // The asset ships in the image, so a release published *before* that image is // deployed points at a URL the origin does not yet serve — and the CDN in // front of it caches that 404 for four hours, which outlives the deploy. It // happened on the first release cut this way: the file was in the container // and the sibling asset served 200, while this one answered 404 from cache. // // A per-release URL cannot inherit another release's cached miss, and it // re-fetches when the asset itself changes. if (signoff && milestones.length > 0) { const separator = signoff.includes("?") ? "&" : "?"; lines.push(`![](${signoff}${separator}v=${encodeURIComponent(tag)})`); } return `${lines.join("\n").trimEnd()}\n`; } const DEFAULTS = { image: process.env.RELEASE_IMAGE ?? "registry.example/owner/project", signoff: process.env.RELEASE_SIGNOFF_URL ?? "", repo: process.env.RELEASE_REPO ?? "owner/project", // What separates publishing from deploying here. Overridable, and settable to // an empty string by a project where the two are the same act. deployNote: process.env.RELEASE_DEPLOY_NOTE ?? "Publishing is not deploying. `deploy.py` moves a running stack to a published image.", registryEnv: process.env.RELEASE_REGISTRY_ENV ?? `${process.env.HOME}/.openclaw/docker-registry.env`, }; const say = (message) => console.error(`release-notes: ${message}`); const die = (message) => { say(message); process.exit(1); }; const git = (...args) => execFileSync("git", args, { encoding: "utf8", timeout: 60_000 }).trim(); /** * The same, with git's own stderr discarded. * * For probes where "no such thing" is the expected answer. `rev-parse` on a tag * that does not exist prints a four-line `fatal:` block before returning * non-zero, and a run that is working correctly should not look like one that * broke. */ const gitQuiet = (...args) => execFileSync("git", args, { encoding: "utf8", timeout: 60_000, stdio: ["ignore", "pipe", "ignore"], }).trim(); /** When the previous release was cut, as an ISO string, or null on the first. */ /** * The release this one follows, whether or not its own commit exists yet. * * `release.sh` asks twice, at two different moments, and that is the whole * reason this is a function rather than `markers[1]`: * * - **before the bump**, to decide whether a batch landed and the version * should take a minor rather than a patch (`release.sh:378`). HEAD is still * the last ordinary commit, so the most recent `chore(release):` *is* the * previous release. * - **after the commit**, to write the notes (`release.sh:658`). Now the most * recent one is this release, and the previous is the one behind it. * * Taking `markers[1]` unconditionally is right only in the second case. In the * first it names the release *before* the previous one, which widened the * window by a whole release: v0.56.0 re-announced "Batch 22", already claimed * by v0.55.0 two minutes after that milestone closed, and took a minor bump for * a batch that had landed in the release before it. It also made `--dry-run` * print a commit list a release too long, so the one artifact meant to be read * by eye before publishing did not match what would publish. * * `markers[0] === headSha` rather than the subject alone: a `chore(release):` * commit somewhere behind HEAD must not make HEAD look like one. */ function previousReleaseCommit(run = git) { const markers = run("log", "--grep=^chore(release):", "--format=%H", "-n", "2") .split("\n") .filter(Boolean); if (markers.length === 0) { return null; } const [headSha, headSubject] = run("log", "-1", "--format=%H%n%s").split("\n"); const headIsRelease = markers[0] === headSha && /^chore\(release\):/.test(headSubject ?? ""); return headIsRelease ? (markers[1] ?? null) : markers[0]; } /** Exported for the test that pins the two moments apart. */ export const _previousReleaseCommit = previousReleaseCommit; export function previousReleaseAt() { const marker = previousReleaseCommit(); return marker ? git("show", "-s", "--format=%cI", marker) : null; } /** * The batches this release finishes. * * A milestone closed since the previous release was cut. That is the unit work * is actually planned in here — batches in the tracker — and it is what makes a * release worth marking: forty-eight were cut in five days, and a sign-off on * every one of them is wallpaper. * * Throws rather than returning `[]` when it cannot ask. "I could not check" and * "no batch landed" are different facts and only one of them is safe to act on * — this one decides both the version bump and the sign-off, so a silent empty * answer would quietly downgrade a release nobody meant to downgrade. */ export async function closedMilestonesSince(since, env) { const url = `https://${env.FORGEJO_REGISTRY}/api/v1/repos/${DEFAULTS.repo}` + "/milestones?state=closed&limit=50"; const auth = Buffer.from( `${env.FORGEJO_REGISTRY_USER}:${env.FORGEJO_REGISTRY_TOKEN}`, ).toString("base64"); const response = await fetch(url, { headers: { Authorization: `Basic ${auth}`, Accept: "application/json" }, signal: AbortSignal.timeout(30_000), redirect: "manual", }); if (!response.ok) throw new Error(`milestones answered HTTP ${response.status}`); const all = await response.json(); if (!Array.isArray(all)) throw new Error("milestones did not answer with a list"); // No boundary means the first release ever cut this way; every closed // milestone predates it, and claiming all of them would be a lie about what // this release contains. if (!since) return []; const after = Date.parse(since); return all .filter((milestone) => { const closed = Date.parse(milestone?.closed_at ?? ""); return Number.isFinite(closed) && closed > after; }) .sort((a, b) => Date.parse(a.closed_at) - Date.parse(b.closed_at)) .map((milestone) => String(milestone.title ?? "").trim()) .filter(Boolean); } /** The commits since the previous release, newest first. */ export function commitsSince() { // No previous release commit means this is the first one ever cut this way. // The whole history is the range rather than nothing — an empty body on a // first release would be the least useful moment to be silent. const marker = previousReleaseCommit(); const range = marker ? `${marker}..HEAD` : "HEAD"; return git("log", range, "--format=%h %s", "--no-merges").split("\n").filter(Boolean); } /** * Credentials, from the file outside the repository. * * The same file and the same three names `release.sh` sources. Read here rather * than inherited, so this can be re-run by hand after a failure without the * caller having to arrange an environment. */ function credentials() { const env = { ...process.env }; try { for (const line of readFileSync(DEFAULTS.registryEnv, "utf8").split("\n")) { const match = /^([A-Z_]+)=(.*)$/.exec(line.trim()); if (match) env[match[1]] = match[2].replace(/^["']|["']$/g, ""); } } catch { // Absent is not fatal here; the check below names what is missing. } // All three, not two. `release.sh` guards on the host and the token and then // dereferences the user unguarded — under `set -u` that aborts inside a // command substitution and presents as "could not list published versions". for (const name of ["FORGEJO_REGISTRY", "FORGEJO_REGISTRY_USER", "FORGEJO_REGISTRY_TOKEN"]) { if (!env[name]) { die(`${name} is not set and not in ${DEFAULTS.registryEnv} — cannot record the release.`); } } return env; } /** The tag, created only if it does not already point somewhere else. */ function ensureTag(tag, dryRun) { const head = git("rev-parse", "HEAD"); let existing = ""; try { existing = gitQuiet("rev-parse", `refs/tags/${tag}`); } catch { existing = ""; } if (existing && existing !== head) { die( `${tag} already exists and points at ${existing.slice(0, 7)}, not ${head.slice(0, 7)}. ` + "A published tag is not moved.", ); } if (existing) { say(`${tag} already tagged here — continuing.`); } else if (dryRun) { say(`[dry-run] git tag -a ${tag}`); } else { git("tag", "-a", tag, "-m", `Release ${tag}`); say(`tagged ${tag}.`); } if (dryRun) { say(`[dry-run] git push origin ${tag}`); return; } try { git("push", "origin", tag); say(`pushed ${tag}.`); } catch (error) { // The shape `.githooks/post-commit` uses: say what is safe, say what to do, // never force, and do not take down the thing that called us. say(`could not push ${tag}: ${error.message.split("\n")[0]}`); say(` The tag is safe locally. Push it when the remote is reachable:`); say(` git push origin ${tag}`); } } /** The release itself. A 409 means somebody already recorded it, which is done. */ async function publish(tag, body, env, dryRun) { const url = `https://${env.FORGEJO_REGISTRY}/api/v1/repos/${DEFAULTS.repo}/releases`; if (dryRun) { say(`[dry-run] POST ${url}`); process.stdout.write(`${body}\n`); return true; } const auth = Buffer.from(`${env.FORGEJO_REGISTRY_USER}:${env.FORGEJO_REGISTRY_TOKEN}`).toString( "base64", ); let response; try { response = await fetch(url, { method: "POST", headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify({ tag_name: tag, name: tag, body }), signal: AbortSignal.timeout(30_000), redirect: "manual", }); } catch (error) { say(`the release call failed: ${error.message}`); return false; } if (response.status === 409) { say(`a release for ${tag} already exists — nothing to do.`); return true; } if (!response.ok) { const detail = (await response.text().catch(() => "")).slice(0, 200); // Status first, and an HTML body diagnosed rather than dumped — a Forgejo // error body can echo the request, and Cloudflare's does not look like one // at all. say( `the release call answered HTTP ${response.status}${ detail.toLowerCase().includes(" null); say(`recorded ${tag}${created?.html_url ? ` — ${created.html_url}` : ""}.`); return true; } async function main(argv) { const dryRun = argv.includes("--dry-run"); // Asked before the version is chosen, so the bump and the sign-off are driven // by the same fact. Prints one title per line and nothing else, because the // caller is a shell reading it. if (argv.includes("--closed-milestones")) { const env = credentials(); const titles = await closedMilestonesSince(previousReleaseAt(), env); for (const title of titles) process.stdout.write(`${title}\n`); return; } const tag = argv.find((arg) => !arg.startsWith("--")); if (!tag) die("usage: release-notes.mjs [--dry-run] | --closed-milestones"); if (!/^v\d+\.\d+\.\d+$/.test(tag)) die(`'${tag}' is not a vX.Y.Z tag.`); const env = credentials(); // Passed down from `release.sh`, which already asked in order to choose the // bump. Asking twice would risk two answers for one release — a milestone // closed between the two calls would bump the version without earning the // sign-off, or the reverse. const milestones = (process.env.RELEASE_MILESTONES ?? "") .split("\n") .map((line) => line.trim()) .filter(Boolean); const body = renderBody({ tag, image: DEFAULTS.image, digest: process.env.RELEASE_DIGEST ?? "", groups: groupCommits(commitsSince()), signoff: DEFAULTS.signoff, milestones, }); ensureTag(tag, dryRun); if (!(await publish(tag, body, env, dryRun))) { say(` The image IS published as ${tag} and the commit is made; only the`); say(` release record is missing. Run: scripts/release-notes.mjs ${tag}`); process.exit(1); } } // Effects only when run, so a test can import the two pure functions above. if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop())) { await main(process.argv.slice(2)); }