Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/release-script-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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: |
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
2 changes: 1 addition & 1 deletion docs/contributing/release-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<target>`, or run `bash scripts/release-smoke/run.sh --version <target>`. 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<target>` 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<target>`; 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<target> --notes-file <CHANGELOG section>`) only if the workflow is unavailable.
8. **GitHub Release — automated.** Pushing the `v<target>` 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<target>`; 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<target> --notes-file <CHANGELOG section>`) only if the workflow is unavailable.
9. **Maven Central publish — automated (from v1.6.6).** The same `v<target>` 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 &mdash; 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=<target>` 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.

Expand Down
116 changes: 116 additions & 0 deletions scripts/release-notes.mjs
Original file line number Diff line number Diff line change
@@ -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 <notes-file> <tag> <repository>
*
* 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() !== "<br><br>";

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 <notes-file> <tag> <owner/repository>");
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.`);
}
}
99 changes: 99 additions & 0 deletions scripts/release-notes.test.mjs
Original file line number Diff line number Diff line change
@@ -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 "## <tag> " 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");