Manifest mode: release-time body re-parse decodes `<`/`>` and silently drops the component after a section with an escaped `<word>`
Related to #1659 (fixed by #1661) and #2801, but a different route in: here htmlEscape did escape the commit subject, the stored PR body is correct, and the component is still silently skipped at release time — because release-please parses the merged PR body twice and the first pass decodes its own escaping.
Environment details
- OS: macOS 15 (also observed on
ubuntu-latestin GitHub Actions) - Node.js version: v24.13.0
- npm version: 11
release-pleaseversion: 17.6.0 (viagoogleapis/[email protected]); reproduced identically on 17.11.2 withnode-html-parser6.1.13- Manifest mode,
separate-pull-requests: false, ~27 packages,include-component-in-tagon
What happens
A commit with subject feat(ci): Record the registry digest and claim :v<version> only on main touched two packages. htmlEscape in src/changelog-notes/default.ts correctly wrote :v<version> into both CHANGELOGs and both <details> sections of the release PR body (verified in the stored body via the API). The PR merged. On the merge run release-please logged, for the second of those two packages only:
✔ Building release for path: services/tezos-indexer
✔ Pull request contains releases, but not for component: tezos-indexer
...
⚠ Expected 27 releases, only found 26
⚠ Missing 1 paths: services/tezos-indexerand the run succeeded. Result: manifest, Cargo.toml and CHANGELOG at the new version, no tag, no GitHub release. The first of the two packages was released, but its GitHub release body contains every later section of the PR as plain text (the swallowed sections' summaries and notes), with :v<version> rendered as :v only on main. The next run then finds no release for the skipped path, regenerates its notes from the whole commit window and links its CHANGELOG at a tag that never existed.
Root cause
Manifest.findMergedReleasePullRequests(manifest.ts#L1146-L1173) runspullRequestOverflowHandler.parseOverflow(pullRequest)→PullRequestBody.parse(body)and then yields{...pullRequest, body: pullRequestBody.toString()}.extractMultipleReleases(pull-request-body.ts#L124-L146) takes each section's notes asdetail.textContent.trim().node-html-parser'stextContentdecodes HTML entities, so<version>becomes<version>inreleaseData[i].notes.PullRequestBody.notes()/toString()(pull-request-body.ts#L68) re-emits those notes verbatim, without re-escaping. The re-serialised body now contains a raw<version>inside each affected section.- Every
Strategy.buildRelease(base.ts#L647) parses that re-serialised body.node-html-parseropens<version>as an unknown element; when the section's</details>arrives it does not match andversionis not inkElementsClosedByClosing, so the closing tag is silently dropped (the// Use aggressive strategy to handle unmatching markups.→breakbranch). The still-open stack at the end of input is[details(a), version, details(b), version];parse()'s repair loop removes each unclosed element and hoists its children into the parent, which deletesdetails(b)(its<summary>becomes stray text insidedetails(a)) and keeps only the outermostdetails(a).getElementsByTagName('details')therefore no longer returnspkg-b. buildReleaselogsPull request contains releases, but not for component: …at info (base.ts#L685) and returns.
The condition is: two or more sections whose decoded notes contain < + letter (one commit touching two packages is the natural way to get there). The first such section survives (it is the outermost and the repair loop skips the last element under root); every later such section is lost. Sections without the token that sit between or after them survive, because they closed properly and are merely nested. That is why the same PR body can lose one package on one merge and none on the next, depending on section order.
#2801's minimal reproduction calls PullRequestBody.parse once and shows the escaped form works — which is exactly why this route is invisible to that test: it needs the round-trip, parse(parse(body).toString()).
Steps to reproduce
npm install --ignore-scripts --save-exact [email protected]- Run the script below (it only exercises
PullRequestBody; no GitHub calls):
const {PullRequestBody} = require('release-please/build/src/util/pull-request-body.js');
// A release PR body exactly as release-please writes it when one commit
// (subject: "feat: claim :v<version> only on main") touches two packages:
// htmlEscape has done its job, both sections carry the ESCAPED form.
const body = [
':robot: I have created a release *beep* *boop*',
'---',
'',
'<details><summary>pkg-a: 1.0.1</summary>\n\n### Features\n\n* claim :v<version> only on main\n</details>',
'',
'<details><summary>pkg-b: 2.0.1</summary>\n\n### Features\n\n* claim :v<version> only on main\n</details>',
'',
'<details><summary>pkg-c: 3.0.1</summary>\n\n### Features\n\n* unrelated\n</details>',
'',
'---',
'footer',
].join('\n');
const components = (prBody) => prBody.releaseData.map((d) => d.component);
// Pass 1: Manifest.findMergedReleasePullRequests -> pullRequestOverflowHandler.parseOverflow
const pass1 = PullRequestBody.parse(body);
console.log('pass 1:', components(pass1));
console.log('pass 1 pkg-a notes still escaped?', pass1.releaseData[0].notes.includes('<version>'));
// Manifest then yields {...pullRequest, body: pullRequestBody.toString()} ...
const reserialised = pass1.toString();
console.log('re-serialised body contains a raw <version>?', reserialised.includes('<version>'));
// ... and every Strategy.buildRelease parses THAT.
const pass2 = PullRequestBody.parse(reserialised);
console.log('pass 2:', components(pass2));
console.log('pass 2 pkg-a notes:', JSON.stringify(pass2.releaseData[0].notes));Output, identical on 17.6.0 and 17.11.2:
pass 1: [ 'pkg-a', 'pkg-b', 'pkg-c' ]
pass 1 pkg-a notes still escaped? false
re-serialised body contains a raw <version>? true
pass 2: [ 'pkg-a', 'pkg-c' ]
pass 2 pkg-a notes: "### Features\n\n* claim :v only on main\n\n\npkg-b: 2.0.1\n\n### Features\n\n* claim :v only on main\n\n\npkg-c: 3.0.1\n\n### Features\n\n* unrelated"pkg-b is gone from pass 2, and pkg-a's notes (which become its GitHub release body) now contain pkg-b's and pkg-c's sections as text.
Suggested fix
Any one of these closes the route; the first two are small:
- In
extractMultipleReleases, read the notes withdetail.innerHTML(orrawText) rather thantextContent, so the escaping release-please wrote survives the round-trip. Because the entities are release-please's own, the notes are already in the formtoString()needs. - Or re-apply
htmlEscape(the same one aschangelog-notes/default.ts, ideally without its inline-code exemption for this purpose) inPullRequestBody.notes()when re-serialising. - Longer term, make
extractMultipleReleasesnot depend on a lenient HTML DOM at all — e.g. split on the<details><summary>…</summary>/</details>markers release-please itself emits — so no text inside a section can affect how sibling sections are found. That would also cover #2801 and the<details>-in-inline-code crash in #2884.
Independently of the parser: Pull request contains releases, but not for component is logged at info while the release PR is being marked autorelease: tagged and the run succeeds. When the body does contain a section for that component (the summary text is right there in a sibling's notes), this is a lost release, and a warn/error would have made it visible.
Real-world case
In a 27-package manifest repo, the release PR merge released 19 of 20 sections; the one dropped was the second of the two packages carrying the escaped subject above. We now run a post-merge step that diffs the manifest against the merge commit's parent and creates any bumped component's missing release, and a commitlint rule that keeps < + letter out of commit subjects and BREAKING CHANGE notes (the latter are not escaped at all by default.ts, so that is a third way into the same parser path).
Source: googleapis/release-please