diff --git a/.github/workflows/release-script-check.yml b/.github/workflows/release-script-check.yml index 4a0fbf593..f329d5c15 100644 --- a/.github/workflows/release-script-check.yml +++ b/.github/workflows/release-script-check.yml @@ -13,12 +13,18 @@ on: push: paths: - 'scripts/cut-release.ps1' + - 'scripts/release-notes.mjs' + - 'scripts/release-notes.test.mjs' + - '.github/workflows/release.yml' - '.github/workflows/release-script-check.yml' # Carries the japicmp previous-release pin the script moves. - 'templates/pom.xml' pull_request: paths: - 'scripts/cut-release.ps1' + - 'scripts/release-notes.mjs' + - 'scripts/release-notes.test.mjs' + - '.github/workflows/release.yml' - '.github/workflows/release-script-check.yml' - 'templates/pom.xml' @@ -32,6 +38,13 @@ jobs: steps: - uses: actions/checkout@v7 + - name: Release notes fit a GitHub Release body + run: | + # release.yml publishes the tag's CHANGELOG section through this script, and + # GitHub refuses a body over 125 000 characters. The oversized case is the + # real 2.4.0 section, the one that failed its tag. + node scripts/release-notes.test.mjs + - name: Resolve a throwaway version on the roadmap's current line shell: pwsh run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9af97402..ea704c5eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,6 +104,11 @@ jobs: echo "::warning::No CHANGELOG section found for ${NOTES_TAG}; using a generic note." printf '%s\n' "Release ${TAG}. See [CHANGELOG.md](CHANGELOG.md) for details." > "${NOTES_FILE}" fi + # GitHub refuses a Release body over 125 000 characters (HTTP 422), which is + # how the v2.4.0 tag's Release failed after its build had passed. A section + # that fits is left as written; a longer one is cut to its entry leads with a + # link to the full section at the tag. + node scripts/release-notes.mjs "${NOTES_FILE}" "${TAG}" "${GITHUB_REPOSITORY}" echo "notes_file=${NOTES_FILE}" >> "${GITHUB_OUTPUT}" # Tags carrying a pre-release suffix (e.g. v1.7.0-rc.1) ship as # GitHub pre-releases so they never become "Latest". diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md index ec41c68d6..c560c0846 100644 --- a/docs/contributing/release-process.md +++ b/docs/contributing/release-process.md @@ -133,7 +133,7 @@ Run within 1 hour of the tag push. Independent steps can run in parallel. 6. **Re-run all examples against the published artifact** — `./mvnw -f examples/pom.xml clean package` followed by `exec:java -Dexec.mainClass=com.demcha.examples.GenerateAllExamples`. Expect one `Generated:` line per example. 6b. **Run the external release-smoke suite** — once Central has indexed the train, dispatch the **Release Smoke** workflow ([`.github/workflows/release-smoke.yml`](../../.github/workflows/release-smoke.yml)) with `version=`, or run `bash scripts/release-smoke/run.sh --version `. This resolves every published coordinate from Maven Central in a clean, GraphCompose-evicted repository (no reactor / local install) and exercises the documented consumer scenarios — the wrapper renders PDF, `graph-compose-core` alone throws `MissingBackendException`, core+render-pdf renders, and templates/testing/bundle perform their roles. It is the authoritative "a real user can install and use this" check; the minimal step-5 snippet resolve is a faster subset. (Release smoke tests **published** artifacts, so it necessarily runs post-publish, not pre-tag.) 7. **Open the next development line** — `pwsh ./scripts/cut-release.ps1 -PostReleaseOnly`. This bumps the train poms to the next patch `-SNAPSHOT` (so develop builds are distinguishable from the release and the japicmp gate compares against it), moves the `graph-compose-templates` japicmp previous-release pin (`japicmp.baseline.previous` in `templates/pom.xml`) onto the release just published, **and** restores linkable "View Code" buttons by flipping ShowcaseMetadata back to `/blob/develop`. The README/showcase install snippets stay on the just-published release. -8. **GitHub Release — automated.** Pushing the `v` tag triggers [`.github/workflows/release.yml`](../../.github/workflows/release.yml): it re-runs `./mvnw clean verify` over the whole reactor against the tagged commit, then creates the Release with that version's CHANGELOG section as the body (hyphenated tags like `v1.7.0-rc.1` ship as pre-releases; the step is idempotent — it edits the notes if the Release already exists). The workflow titles it `GraphCompose v`; for a **minor** release, edit the title to add the codename (`v1.4`=cinematic, `v1.5`=intuitive, `v1.6`=expressive; patches drop it). Create the Release by hand (`gh release create v --notes-file `) only if the workflow is unavailable. +8. **GitHub Release — automated.** Pushing the `v` tag triggers [`.github/workflows/release.yml`](../../.github/workflows/release.yml): it re-runs `./mvnw clean verify` over the whole reactor against the tagged commit, then creates the Release with that version's CHANGELOG section as the body (hyphenated tags like `v1.7.0-rc.1` ship as pre-releases; the step is idempotent — it edits the notes if the Release already exists). GitHub refuses a Release body over 125 000 characters, so the section first goes through [`scripts/release-notes.mjs`](../../scripts/release-notes.mjs): one that fits is published as written, and a longer one is published as its subsection headings and each entry's bold lead, with a link to the full section at the tag. The 2.4.0 section, at over 156 000 characters, failed its tag's Release before this existed. The workflow titles it `GraphCompose v`; for a **minor** release, edit the title to add the codename (`v1.4`=cinematic, `v1.5`=intuitive, `v1.6`=expressive; patches drop it). Create the Release by hand (`gh release create v --notes-file `) only if the workflow is unavailable. 9. **Maven Central publish — automated (from v1.6.6).** The same `v` tag push triggers [`.github/workflows/publish.yml`](../../.github/workflows/publish.yml): it re-runs `mvnw verify` at the tagged commit, signs each module's artefacts (main / sources / javadoc / pom — the `graph-compose` wrapper has no sources of its own, so it publishes no sources jar) with the repo's GPG key, and uploads to Maven Central via the `central-publishing-maven-plugin`. Hyphenated tags (`-rc`, `-alpha`, `-beta`, `-snapshot`) are skipped — those go only to the GitHub Release pre-release surface. `autoPublish=false` in the plugin config means the artefact lands in the Central validation queue; the maintainer flips the switch on [central.sonatype.com](https://central.sonatype.com) for the first publish, then can opt into auto-release in a follow-up. Verify via `mvn dependency:get -DgroupId=io.github.demchaav -DartifactId=graph-compose -Dversion=` once the artifact appears (usually 5–15 minutes after the workflow turns green). 10. **Optional**: GitHub Discussions announcement (mirror the prior release's style; close with *"author intent, not coordinates"*), LinkedIn post, r/java post. diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs new file mode 100644 index 000000000..543e67e8a --- /dev/null +++ b/scripts/release-notes.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +/** + * scripts/release-notes.mjs — fit a CHANGELOG section into a GitHub Release body. + * + * node scripts/release-notes.mjs + * + * Exit 0 the file fits (rewritten or untouched) · 2 usage. + * + * release.yml publishes a version's CHANGELOG section as the body of its GitHub + * Release, and GitHub refuses a body longer than 125 000 characters with HTTP 422. + * The 2.4.0 section was 156 808, so the tag's Release step failed after the build + * and the knowledge bundle had both passed. + * + * A section that fits is left byte for byte. One that does not is rewritten as its + * heading, every `###` subsection, and each entry cut to its bold lead sentence — + * the line the CHANGELOG already writes to be read on its own — followed by a link + * to the full section at the tag. Every entry stays listed; only the explanation + * under it moves behind the link. An entry whose bold lead is only a name + * (`SvgGlyph.fromFile(Path)`) keeps the rest of its first sentence, since the name + * alone says nothing. + */ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +/** GitHub's limit is 125 000; the margin absorbs the counting difference for astral characters. */ +export const BODY_LIMIT = 120_000; + +/** + * The body to publish for a CHANGELOG section. + * + * @param {string} notes the extracted section, heading first + * @param {string} tag the tag being released, e.g. v2.4.0 + * @param {string} repository owner/name, e.g. DemchaAV/GraphCompose + * @returns {string} {@code notes} itself when it fits, otherwise the condensed body + */ +export function releaseBody(notes, tag, repository) { + if (notes.length <= BODY_LIMIT) { + return notes; + } + const lines = notes.replace(/\r\n/g, "\n").split("\n"); + const out = [lines[0], ""]; + let i = 1; + while (i < lines.length) { + const line = lines[i]; + if (line.startsWith("### ")) { + out.push("", line, ""); + i++; + continue; + } + if (line.startsWith("- **")) { + const [lead, next] = leadOf(lines, i); + out.push(`- ${lead}`); + i = next; + continue; + } + i++; + } + const url = `https://github.com/${repository}/blob/${tag}/CHANGELOG.md`; + out.push( + "", + "---", + "", + `Each entry above is the first line of its CHANGELOG entry. The full notes — what changed, why, and how it was verified — are longer than a GitHub Release body allows; read them in [CHANGELOG.md at ${tag}](${url}).`, + ); + return out.join("\n").replace(/\n{3,}/g, "\n\n") + "\n"; +} + +/** The bold lead of the entry starting at {@code start}, and the index after the lines it read. */ +function leadOf(lines, start) { + let text = lines[start].slice(2); + let next = start + 1; + const continues = () => next < lines.length && lines[next].startsWith(" ") && lines[next].trim() !== "

"; + + while (text.indexOf("**", 2) < 0 && continues()) { + text += " " + lines[next].trim(); + next++; + } + const close = text.indexOf("**", 2); + if (close < 0) { + // No closing marker: publish the first line as written rather than guess. + return [lines[start].slice(2), start + 1]; + } + let lead = text.slice(0, close + 2); + const nameOnly = /^\*\*`[^`]+`\.?\*\*$/.test(lead); + if (nameOnly || !/[.:!?]\*\*$/.test(lead)) { + let rest = text.slice(close + 2); + while (!/[.:](\s|$)/.test(rest) && continues()) { + rest += " " + lines[next].trim(); + next++; + } + const stop = rest.search(/[.:](\s|$)/); + lead += (stop < 0 ? rest : rest.slice(0, stop + 1)).replace(/:$/, "."); + } + // Skip the remainder of this entry. + while (continues()) { + next++; + } + return [lead, next]; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const [file, tag, repository] = process.argv.slice(2); + if (!file || !tag || !repository) { + console.error("usage: node scripts/release-notes.mjs "); + process.exit(2); + } + const notes = fs.readFileSync(file, "utf8"); + const body = releaseBody(notes, tag, repository); + if (body === notes) { + console.log(`[release-notes] ${notes.length} characters — published as written`); + } else { + fs.writeFileSync(file, body); + console.log(`::notice::CHANGELOG section for ${tag} is ${notes.length} characters, over the ${BODY_LIMIT} a Release body can take; published its ${(body.match(/^- /gm) || []).length} entry leads (${body.length} characters) with a link to the full notes.`); + } +} diff --git a/scripts/release-notes.test.mjs b/scripts/release-notes.test.mjs new file mode 100644 index 000000000..d01f1d800 --- /dev/null +++ b/scripts/release-notes.test.mjs @@ -0,0 +1,99 @@ +/** + * node scripts/release-notes.test.mjs — exit 0 when every case holds. + * + * The oversized case is the real 2.4.0 CHANGELOG section, the one GitHub refused, + * extracted the way release.yml extracts it. + */ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { BODY_LIMIT, releaseBody } from "./release-notes.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repository = "DemchaAV/GraphCompose"; + +/** release.yml's awk: the "## " heading and every line up to the next "## v". */ +function section(changelog, tag) { + const out = []; + let on = false; + for (const line of changelog.replace(/\r\n/g, "\n").split("\n")) { + if (line.startsWith(`## ${tag} `)) { on = true; out.push(line); continue; } + if (on && line.startsWith("## v")) break; + if (on) out.push(line); + } + return out.join("\n") + "\n"; +} + +const changelog = fs.readFileSync(path.join(root, "CHANGELOG.md"), "utf8"); +let failures = 0; +function check(name, body) { + try { body(); console.log(` ok ${name}`); } + catch (error) { failures++; console.log(` FAIL ${name}\n ${error.message.split("\n")[0]}`); } +} + +// 1. A section that fits is published exactly as written. +check("a section under the limit is returned unchanged", () => { + const notes = section(changelog, "v2.3.0"); + assert.ok(notes.length > 1000, "fixture: the v2.3.0 section was not found"); + assert.equal(releaseBody(notes, "v2.3.0", repository), notes); +}); + +// 2. The section GitHub refused now fits, and nothing is dropped from its list. +const big = section(changelog, "v2.4.0"); +const body = releaseBody(big, "v2.4.0", repository); +check("fixture: the v2.4.0 section is over the limit", () => { + assert.ok(big.length > BODY_LIMIT, `v2.4.0 is ${big.length} characters — the oversized case no longer exercises anything`); +}); +check("the condensed body fits a GitHub Release", () => { + assert.ok(body.length <= BODY_LIMIT, `${body.length} characters`); + assert.ok(body.length < 125_000); +}); +check("it opens with the section heading", () => { + assert.equal(body.split("\n")[0], big.split("\n")[0]); +}); +check("every subsection heading survives, in order", () => { + const headings = (text) => text.split("\n").filter((l) => l.startsWith("### ")); + assert.deepEqual(headings(body), headings(big)); +}); +check("every entry survives as one line", () => { + const entries = (text) => text.split("\n").filter((l) => l.startsWith("- **")).length; + assert.equal(entries(body), entries(big)); +}); +check("every entry keeps its bold lead", () => { + for (const line of body.split("\n").filter((l) => l.startsWith("- "))) { + assert.match(line, /^- \*\*.+\*\*/, line); + } +}); +check("a lead that is only a name carries its first sentence", () => { + const line = body.split("\n").find((l) => l.startsWith("- **`SvgGlyph.fromFile(Path)`")); + assert.ok(line, "the SvgGlyph.fromFile entry is missing"); + assert.match(line, /^- \*\*`SvgGlyph\.fromFile\(Path\)`\.\*\* The classpath variant covers/); +}); +check("it links to the full section at the tag", () => { + assert.ok(body.includes("(https://github.com/DemchaAV/GraphCompose/blob/v2.4.0/CHANGELOG.md)")); +}); + +// 3. A lead with no closing marker is published as its first line, not dropped. +check("an unterminated lead is kept as written", () => { + const notes = "## v9.9.9 — Planned\n\n### Public API\n\n- **Broken lead without a close\n continuation\n" + "x".repeat(BODY_LIMIT); + const out = releaseBody(notes, "v9.9.9", repository); + assert.ok(out.includes("- **Broken lead without a close\n")); + assert.ok(out.length <= BODY_LIMIT); +}); + +// 4. The tag's workflow still runs it, between the extraction and the Release. +check("release.yml fits the notes after extracting them and before publishing", () => { + const workflow = fs.readFileSync(path.join(root, ".github/workflows/release.yml"), "utf8"); + const extract = workflow.indexOf("' CHANGELOG.md > \"${NOTES_FILE}\""); + const fit = workflow.search(/^\s*node scripts\/release-notes\.mjs "\$\{NOTES_FILE\}"/m); + const publish = workflow.indexOf("gh release create"); + assert.ok(extract >= 0 && publish >= 0, "release.yml no longer extracts the section or creates the Release the way this test reads it"); + assert.ok(fit > extract && fit < publish, "release.yml does not run scripts/release-notes.mjs between the extraction and gh release create"); +}); + +if (failures > 0) { + console.log(`release-notes: ${failures} case(s) failed`); + process.exit(1); +} +console.log("release-notes: all cases passed");