Project-Template/docs/architecture/scripts/release-notes.mjs

606 lines
22 KiB
JavaScript
Raw Permalink Normal View History

#!/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, "//<redacted>@"],
[/([?&](?:token|key|secret|password|access_token|api_key)=)[^&\s]+/gi, "$1<redacted>"],
[/pllc_[a-z]+_[0-9a-f]{8,}/g, "<redacted-token>"],
[/v2:[0-9a-f]{8}:[^\s"]+/g, "<redacted-envelope>"],
[/\b(Bearer|token|Basic)\s+[A-Za-z0-9._~+/=-]{12,}/gi, "$1 <redacted>"],
[/\b[0-9a-f]{40,}\b/gi, "<redacted-hex>"],
];
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("<html")
? " with an HTML body — a proxy answered, not Forgejo"
: detail
? `: ${detail}`
: ""
}`,
);
if (response.status === 401 || response.status === 403) {
say(` Check FORGEJO_REGISTRY_TOKEN in ${DEFAULTS.registryEnv}.`);
}
return false;
}
const created = await response.json().catch(() => 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 <vX.Y.Z> [--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));
}