606 lines
22 KiB
JavaScript
Executable File
606 lines
22 KiB
JavaScript
Executable File
#!/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(`})`);
|
|
}
|
|
|
|
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));
|
|
}
|