From 08fad230b125a24697e5a14946d953a78a6ac325 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 12:18:09 +0100 Subject: [PATCH 1/4] fix(release): make UPGRADING extraction fence-aware and absolutise its links --- scripts/ci/compose-release-notes.mjs | 171 +++++++++++++++- scripts/ci/compose-release-notes.test.mjs | 231 ++++++++++++++++++++++ 2 files changed, 398 insertions(+), 4 deletions(-) diff --git a/scripts/ci/compose-release-notes.mjs b/scripts/ci/compose-release-notes.mjs index f808443c1..f61afc71a 100644 --- a/scripts/ci/compose-release-notes.mjs +++ b/scripts/ci/compose-release-notes.mjs @@ -92,18 +92,63 @@ export function parseChecksum(checksumText, assetName) { return { sha256: null, error: 'checksum file contains no checksum line' } } +/** + * A CommonMark fenced-code-block delimiter: up to three leading spaces, then a + * run of at least three backticks or tildes, then the info string (opening) or + * trailing whitespace only (closing). + */ +const FENCE_DELIMITER = /^ {0,3}(`{3,}|~{3,})(.*)$/ + +/** + * Advance the fenced-code-block state by one line. `state` is `null` outside a + * fence and `{char, length}` inside one. UPGRADING.md carries `sql` and + * `powershell` samples whose contents start with `#`, so every scan over the + * document has to know whether the line it is looking at is prose or sample + * text — a `# comment` inside a fence is neither a heading nor a link context. + */ +function advanceFenceState(state, line) { + const match = FENCE_DELIMITER.exec(line) + if (!match) return state + const [, marker, info] = match + const char = marker[0] + if (state === null) { + // An opening backtick fence may not carry a backtick in its info string. + if (char === '`' && info.includes('`')) return null + return { char, length: marker.length } + } + // A fence closes only on its own character, at least as long as the opener, + // with nothing but whitespace after the run. + if (char === state.char && marker.length >= state.length && info.trim() === '') { + return null + } + return state +} + /** * Lift the `## …` section out of UPGRADING.md. Headings carry a date or a * label after the tag (`## v0.2.0 — 2026-08-29`), so the match is on the tag * followed by a boundary — never a bare prefix, which would let `v0.1.0` match * a `v0.1.0-rc.1` heading. + * + * The scan is fence-aware in both directions (#2250): a `#`/`##` line inside a + * fenced block neither starts a section nor ends one, so a shell comment or a + * Markdown sample can no longer truncate the section or be mistaken for its + * heading. An UNTERMINATED fence runs to the end of the document, which is what + * a Markdown renderer does with the same input — the section is then over-long + * rather than silently cut at a sample line. */ export function extractUpgradingSection(markdown, tag) { if (typeof markdown !== 'string') return null const lines = markdown.replace(/\r\n/g, '\n').split('\n') + let fence = null let start = -1 for (let index = 0; index < lines.length; index += 1) { - const heading = /^## +(.*)$/.exec(lines[index]) + const line = lines[index] + const wasInFence = fence !== null + fence = advanceFenceState(fence, line) + // Skip the delimiter lines themselves and everything between them. + if (wasInFence || fence !== null) continue + const heading = /^## +(.*)$/.exec(line) if (!heading) continue const text = heading[1].trim() if (text === tag || text.startsWith(`${tag} `)) { @@ -113,14 +158,128 @@ export function extractUpgradingSection(markdown, tag) { } if (start === -1) return null const body = [] + fence = null for (let index = start; index < lines.length; index += 1) { - if (/^#{1,2} +/.test(lines[index])) break - body.push(lines[index]) + const line = lines[index] + const wasInFence = fence !== null + fence = advanceFenceState(fence, line) + if (!wasInFence && fence === null && /^#{1,2} +/.test(line)) break + body.push(line) } const section = body.join('\n').trim() return section === '' ? null : section } +/** + * Rewrite one Markdown link destination for the release page. A release body is + * rendered outside any file, so a destination that resolves against UPGRADING.md + * in the repository resolves against nothing here. + * + * Left exactly as written: any destination carrying a scheme (`https:`, + * `mailto:`, and every other), and any root-relative `/path` — GitHub already + * resolves those against the repository host. + */ +function rewriteDestination(destination, base) { + if (destination === '') return destination + if (destination.startsWith('#')) return `${base}UPGRADING.md${destination}` + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(destination)) return destination + if (destination.startsWith('/')) return destination + const hashAt = destination.indexOf('#') + const path = hashAt === -1 ? destination : destination.slice(0, hashAt) + const fragment = hashAt === -1 ? '' : destination.slice(hashAt) + const cleaned = path.replace(/^\.\//, '') + if (cleaned === '') return destination + // Path segments are kept verbatim: they are already repository paths, and + // re-encoding them would break the `/` separators the blob URL needs. + return `${base}${cleaned}${fragment}` +} + +/** `](dest)` or `](dest "title")`, the shape UPGRADING.md actually writes. */ +const INLINE_LINK = /(\]\()([^()\s]+)((?:[ \t]+(?:"[^"]*"|'[^']*'|\([^()]*\)))?[ \t]*\))/g + +/** `[id]: dest` at the start of a line, optionally followed by a title. */ +const REFERENCE_DEFINITION = /^( {0,3}\[[^\]]+\]:[ \t]*)(\S+)([ \t]*.*)$/ + +function rewriteLinksInText(text, base) { + return text.replace(INLINE_LINK, (whole, open, destination, close) => { + return `${open}${rewriteDestination(destination, base)}${close}` + }) +} + +/** + * Apply `rewriteText` to everything on the line EXCEPT inline code spans. A + * span opens on a run of N backticks and closes on the next run of exactly N; + * an unclosed run is literal text and is rewritten with the rest of the line. + */ +function rewriteOutsideInlineCode(line, rewriteText) { + let out = '' + let plainStart = 0 + let index = 0 + while (index < line.length) { + if (line[index] !== '`') { + index += 1 + continue + } + const runStart = index + while (index < line.length && line[index] === '`') index += 1 + const runLength = index - runStart + let search = index + let closeEnd = -1 + while (search < line.length) { + if (line[search] !== '`') { + search += 1 + continue + } + const closeStart = search + while (search < line.length && line[search] === '`') search += 1 + if (search - closeStart === runLength) { + closeEnd = search + break + } + } + if (closeEnd === -1) continue + out += rewriteText(line.slice(plainStart, runStart)) + out += line.slice(runStart, closeEnd) + index = closeEnd + plainStart = closeEnd + } + return out + rewriteText(line.slice(plainStart)) +} + +/** + * Make every relative link in a lifted Markdown section absolute against the + * tag being published (#2250). Bare anchors resolve against UPGRADING.md, the + * only document whose headings they can name; relative paths resolve against + * the repository root at the tag, matching `upgradingUrl` above (including its + * `encodeURIComponent(tag)` convention). + * + * Untouched: fenced code blocks, inline code spans, absolute and scheme-bearing + * destinations, and root-relative paths. Image destinations (`![alt](path)`) + * share the `](` shape and get the same rewrite; a relative image is broken on + * a release page either way (a working one would need a `raw.githubusercontent` + * URL), and UPGRADING.md has no images today. + */ +export function rewriteRelativeLinks(markdown, { repo, tag } = {}) { + if (typeof markdown !== 'string') return markdown + if (typeof repo !== 'string' || repo === '' || typeof tag !== 'string' || tag === '') return markdown + const base = `https://github.com/${repo}/blob/${encodeURIComponent(tag)}/` + let fence = null + return markdown + .split('\n') + .map((line) => { + const wasInFence = fence !== null + fence = advanceFenceState(fence, line) + if (wasInFence || fence !== null) return line + const definition = REFERENCE_DEFINITION.exec(line) + if (definition) { + const [, label, destination, rest] = definition + return `${label}${rewriteDestination(destination, base)}${rest}` + } + return rewriteOutsideInlineCode(line, (text) => rewriteLinksInText(text, base)) + }) + .join('\n') +} + /** Drop a leading `# Title` line: the release page supplies its own heading. */ function stripLeadingTitle(markdown) { const text = markdown.replace(/\r\n/g, '\n').trim() @@ -228,9 +387,13 @@ export function composeReleaseNotes({ ) // --- 2. Breaking changes, from the tag's UPGRADING section --------------- + // A release body is not a file in the tree, so relative links and bare + // anchors lifted out of UPGRADING.md are dead here (#2250). Only this + // section is rewritten: the curated notes file carries no relative-link + // shapes today, and the generated changelog is already absolute. const upgradingSection = extractUpgradingSection(upgradingText, tag) if (upgradingSection) { - sections.push(`## Breaking changes\n\n${upgradingSection}`) + sections.push(`## Breaking changes\n\n${rewriteRelativeLinks(upgradingSection, { repo, tag })}`) } else { record(`UPGRADING.md has no "## ${tag}" section`) sections.push( diff --git a/scripts/ci/compose-release-notes.test.mjs b/scripts/ci/compose-release-notes.test.mjs index 26cbdaa80..5979d62fc 100644 --- a/scripts/ci/compose-release-notes.test.mjs +++ b/scripts/ci/compose-release-notes.test.mjs @@ -25,6 +25,7 @@ import { extractUpgradingSection, parseChecksum, parseArgs, + rewriteRelativeLinks, MAX_RELEASE_BODY_LENGTH, } from './compose-release-notes.mjs' @@ -208,6 +209,138 @@ test('an UPGRADING heading with no body is treated as missing', () => { assert.equal(extractUpgradingSection(empty, RC_TAG), null) }) +// ----------------------------------------------------------------------------- +// 4b. Fenced code blocks are content, never headings (#2250 item 5) +// ----------------------------------------------------------------------------- + +const FENCED = [ + '# Version notes', + '', + `## ${RC_TAG} — release candidate`, + '', + 'Run the migration by hand:', + '', + '```bash', + '# comment that is not a heading', + '## also not a heading', + 'taskdeck migrate', + '```', + '', + 'Then restart the service.', + '', + '## v0.2.0 — 2026-08-29', + '', + 'older notes', + '', +].join('\n') + +test('a fenced block inside the section is kept whole, hash lines and all', () => { + const section = extractUpgradingSection(FENCED, RC_TAG) + assert.ok(section.includes('# comment that is not a heading'), section) + assert.ok(section.includes('## also not a heading'), section) + assert.ok(section.includes('taskdeck migrate'), section) + assert.ok( + section.endsWith('Then restart the service.'), + `the section must run to the next real heading, got: ${section}`, + ) + assert.ok(!section.includes('older notes'), 'extraction still stops at the next version heading') +}) + +test('a fence closed before the next heading restores heading detection', () => { + const doc = [ + `## ${RC_TAG}`, + '', + '~~~text', + '## inside a tilde fence', + '~~~', + '', + 'after the fence', + '', + '## v0.2.0', + '', + 'older notes', + ].join('\n') + const section = extractUpgradingSection(doc, RC_TAG) + assert.ok(section.includes('## inside a tilde fence'), section) + assert.ok(section.includes('after the fence'), section) + assert.ok(!section.includes('older notes'), section) +}) + +test('a tag heading that only appears inside a fence is not a section start', () => { + const doc = [ + '# Version notes', + '', + '```markdown', + `## ${RC_TAG}`, + 'sample body', + '```', + '', + '## v0.2.0', + '', + 'older notes', + ].join('\n') + assert.equal(extractUpgradingSection(doc, RC_TAG), null) +}) + +test('an unterminated fence runs to the end of the document', () => { + const doc = [ + `## ${RC_TAG}`, + '', + '```bash', + '## looks like a heading', + '', + '## v0.2.0', + '', + 'older notes', + ].join('\n') + const section = extractUpgradingSection(doc, RC_TAG) + assert.ok(section.includes('## v0.2.0'), 'an unclosed fence swallows the rest, as a Markdown renderer would') + assert.ok(section.includes('older notes'), section) +}) + +test('a closing fence must match the opening character and length', () => { + const doc = [ + `## ${RC_TAG}`, + '', + '````text', + '```', + '## still inside the outer fence', + '````', + '', + 'after the fence', + '', + '## v0.2.0', + '', + 'older notes', + ].join('\n') + const section = extractUpgradingSection(doc, RC_TAG) + assert.ok(section.includes('## still inside the outer fence'), section) + assert.ok(section.includes('after the fence'), section) + assert.ok(!section.includes('older notes'), section) +}) + +test('a fence indented up to three spaces still opens a block', () => { + const doc = [ + `## ${RC_TAG}`, + '', + '- step one:', + '', + ' ```sql', + '## not a heading', + ' ```', + '', + 'after the fence', + '', + '## v0.2.0', + '', + 'older notes', + ].join('\n') + const section = extractUpgradingSection(doc, RC_TAG) + assert.ok(section.includes('## not a heading'), section) + assert.ok(section.includes('after the fence'), section) + assert.ok(!section.includes('older notes'), section) +}) + test('a missing UPGRADING section is a WARNING for an RC and a fallback pointer', () => { const { body, warnings, errors } = compose({ upgradingText: '# Upgrading Taskdeck\n' }) assert.deepEqual(errors, [], 'an RC still publishes') @@ -493,3 +626,101 @@ test('the shipped UPGRADING.md yields a section for the last stable tag', () => const section = extractUpgradingSection(upgrading, 'v0.2.0') assert.ok(section && section.includes('BREAKING'), 'the real document must match the extractor the workflow uses') }) + +// ----------------------------------------------------------------------------- +// 11. Relative links are absolute on the release page (#2250 item 5) +// ----------------------------------------------------------------------------- + +const LINK_BASE = `https://github.com/${REPO}/blob/${RC_TAG}` + +function rewrite(markdown) { + return rewriteRelativeLinks(markdown, { repo: REPO, tag: RC_TAG }) +} + +test('a bare anchor resolves against UPGRADING.md at the tag', () => { + assert.equal( + rewrite('restore the [snapshot](#automatic-pre-migration-backups) first'), + `restore the [snapshot](${LINK_BASE}/UPGRADING.md#automatic-pre-migration-backups) first`, + ) +}) + +test('a relative path becomes a blob URL at the tag, with and without a ./ prefix', () => { + assert.equal( + rewrite('[guide](docs/platform/LLM_PROVIDER_SETUP_GUIDE.md)'), + `[guide](${LINK_BASE}/docs/platform/LLM_PROVIDER_SETUP_GUIDE.md)`, + ) + assert.equal(rewrite('[guide](./docs/platform/x.md)'), `[guide](${LINK_BASE}/docs/platform/x.md)`) +}) + +test('a fragment on a relative path is preserved', () => { + assert.equal(rewrite('[x](docs/x.md#a-section)'), `[x](${LINK_BASE}/docs/x.md#a-section)`) +}) + +test('absolute, root-relative and mailto destinations are left untouched', () => { + const untouched = [ + '[a](https://github.com/Chris0Jeky/Taskdeck/pull/2248)', + '[b](http://example.com/x)', + '[c](mailto:someone@example.com)', + '[d](/already/root/relative.md)', + ].join('\n') + assert.equal(rewrite(untouched), untouched) +}) + +test('a link inside a fenced code block is left untouched', () => { + const doc = ['```markdown', '[x](docs/x.md)', '```', '[y](docs/y.md)'].join('\n') + assert.equal( + rewrite(doc), + ['```markdown', '[x](docs/x.md)', '```', `[y](${LINK_BASE}/docs/y.md)`].join('\n'), + ) +}) + +test('a link inside an inline code span is left untouched', () => { + assert.equal( + rewrite('write `[x](docs/x.md)` and link [y](docs/y.md)'), + `write \`[x](docs/x.md)\` and link [y](${LINK_BASE}/docs/y.md)`, + ) +}) + +test('a reference-style link definition gets the same treatment', () => { + assert.equal(rewrite('[guide]: docs/x.md'), `[guide]: ${LINK_BASE}/docs/x.md`) + assert.equal(rewrite('[anchor]: #automatic-pre-migration-backups'), `[anchor]: ${LINK_BASE}/UPGRADING.md#automatic-pre-migration-backups`) + assert.equal(rewrite('[keep]: https://example.com/x'), '[keep]: https://example.com/x') +}) + +test('the tag is percent-encoded exactly as the UPGRADING fallback link encodes it', () => { + const oddTag = 'v1.0.0+build/1' + assert.equal( + rewriteRelativeLinks('[x](docs/x.md)', { repo: REPO, tag: oddTag }), + `[x](https://github.com/${REPO}/blob/${encodeURIComponent(oddTag)}/docs/x.md)`, + ) +}) + +const UPGRADING_WITH_LINKS = UPGRADING.replace( + '- The `AddApiKeyScopes` migration backfills existing keys to Full.', + '- Restore the [snapshot](#automatic-pre-migration-backups); see [the guide](docs/platform/LLM_PROVIDER_SETUP_GUIDE.md).', +) + +test('the composed page carries absolute links for the UPGRADING section', () => { + const { body, errors } = compose({ upgradingText: UPGRADING_WITH_LINKS }) + assert.deepEqual(errors, []) + assert.ok(body.includes(`${LINK_BASE}/UPGRADING.md#automatic-pre-migration-backups`), body) + assert.ok(body.includes(`${LINK_BASE}/docs/platform/LLM_PROVIDER_SETUP_GUIDE.md`), body) + assert.ok(!body.includes('](#automatic-pre-migration-backups)'), 'no bare anchor may survive onto the page') +}) + +test('the release-body length guard still fires after links are rewritten', () => { + const huge = [ + '# Version notes', + '', + `## ${RC_TAG} — release candidate (prerelease)`, + '', + '[x](docs/x.md)', + '', + 'y'.repeat(MAX_RELEASE_BODY_LENGTH), + '', + ].join('\n') + const { body, errors } = compose({ upgradingText: huge }) + assert.ok(body.includes(`${LINK_BASE}/docs/x.md`), 'the rewrite still ran on the oversized section') + const overflow = errors.find((e) => e.includes('over the')) + assert.ok(overflow, `expected a length error, got: ${JSON.stringify(errors)}`) +}) From 9b17d4e70cdce37f6b29ce6dd487605ac85da733 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 12:18:09 +0100 Subject: [PATCH 2/4] ci: run the release notes composer suite in ci-required --- .github/workflows/ci-required.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci-required.yml b/.github/workflows/ci-required.yml index 94e6f5b1c..a9f0f2282 100644 --- a/.github/workflows/ci-required.yml +++ b/.github/workflows/ci-required.yml @@ -130,6 +130,9 @@ jobs: - name: Validate release cache trust contract run: node --test scripts/ci/release-cache-contract.test.mjs + - name: Validate release notes composer + run: node --test scripts/ci/compose-release-notes.test.mjs + backend-architecture: name: Backend Architecture uses: ./.github/workflows/reusable-backend-architecture.yml From a6ad4aff9e0951630ac26be0a652165cfc338895 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 12:31:41 +0100 Subject: [PATCH 3/4] fix(release): fail closed on an unterminated UPGRADING fence and tighten link rewriting An unterminated fence made extractUpgradingSection run to the end of the document, publishing every older version's notes under '## Breaking changes'; the body-length guard could not catch it because UPGRADING.md is ~20 KB. It now throws MalformedUpgradingSectionError, which composeReleaseNotes turns into an error for BOTH tag classes. Also: angle-bracketed destinations are unwrapped, rewritten and re-wrapped instead of being concatenated onto the blob base verbatim; the reference-definition match now requires end-of-line or a CommonMark title, so prose shaped '[Note]: see ...' is left alone and its inline links are still processed. --- scripts/ci/compose-release-notes.mjs | 66 ++++++++++++-- scripts/ci/compose-release-notes.test.mjs | 103 +++++++++++++++++++--- 2 files changed, 148 insertions(+), 21 deletions(-) diff --git a/scripts/ci/compose-release-notes.mjs b/scripts/ci/compose-release-notes.mjs index f61afc71a..ac134183e 100644 --- a/scripts/ci/compose-release-notes.mjs +++ b/scripts/ci/compose-release-notes.mjs @@ -26,6 +26,11 @@ // * RC — both degrade to a warning: highlights are omitted, breaking // changes fall back to "see UPGRADING.md". // +// A MALFORMED UPGRADING section — one whose fenced code block is never closed — +// is an ERROR for BOTH classes. Missing is recoverable by a pointer; silently +// wrong is not, and an open fence would lift every older version's notes onto +// the page under `## Breaking changes`. +// // `composeReleaseNotes` is pure (strings in, string out) so the whole policy is // unit-testable without a runner: see `compose-release-notes.test.mjs`. // @@ -92,6 +97,19 @@ export function parseChecksum(checksumText, assetName) { return { sha256: null, error: 'checksum file contains no checksum line' } } +/** + * Thrown by `extractUpgradingSection` when the section it lifted is not a + * well-formed Markdown document. The tag is carried on the error so the caller + * can name it on the `::error` line that fails the compose. + */ +export class MalformedUpgradingSectionError extends Error { + constructor(message, tag) { + super(message) + this.name = 'MalformedUpgradingSectionError' + this.tag = tag + } +} + /** * A CommonMark fenced-code-block delimiter: up to three leading spaces, then a * run of at least three backticks or tildes, then the info string (opening) or @@ -133,9 +151,13 @@ function advanceFenceState(state, line) { * The scan is fence-aware in both directions (#2250): a `#`/`##` line inside a * fenced block neither starts a section nor ends one, so a shell comment or a * Markdown sample can no longer truncate the section or be mistaken for its - * heading. An UNTERMINATED fence runs to the end of the document, which is what - * a Markdown renderer does with the same input — the section is then over-long - * rather than silently cut at a sample line. + * heading. An UNTERMINATED fence would make the section run to the end of the + * document and publish every older version's notes under `## Breaking changes`, + * so it FAILS CLOSED instead: a `MalformedUpgradingSectionError` names the tag + * and stops the compose. The body-length guard cannot catch this — the whole of + * UPGRADING.md is far under `MAX_RELEASE_BODY_LENGTH`. + * + * @throws {MalformedUpgradingSectionError} the section ends inside an open fence */ export function extractUpgradingSection(markdown, tag) { if (typeof markdown !== 'string') return null @@ -166,6 +188,13 @@ export function extractUpgradingSection(markdown, tag) { if (!wasInFence && fence === null && /^#{1,2} +/.test(line)) break body.push(line) } + if (fence !== null) { + throw new MalformedUpgradingSectionError( + `unterminated fenced code block in the UPGRADING section for ${tag} — ` + + 'close the fence in UPGRADING.md before tagging', + tag, + ) + } const section = body.join('\n').trim() return section === '' ? null : section } @@ -178,9 +207,16 @@ export function extractUpgradingSection(markdown, tag) { * Left exactly as written: any destination carrying a scheme (`https:`, * `mailto:`, and every other), and any root-relative `/path` — GitHub already * resolves those against the repository host. + * + * CommonMark's angle-bracket form (`]()`) is unwrapped, rewritten and + * re-wrapped: concatenating `` onto the blob base verbatim would + * publish a dead `.../blob//` link. */ function rewriteDestination(destination, base) { if (destination === '') return destination + if (destination.length > 1 && destination.startsWith('<') && destination.endsWith('>')) { + return `<${rewriteDestination(destination.slice(1, -1), base)}>` + } if (destination.startsWith('#')) return `${base}UPGRADING.md${destination}` if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(destination)) return destination if (destination.startsWith('/')) return destination @@ -197,8 +233,14 @@ function rewriteDestination(destination, base) { /** `](dest)` or `](dest "title")`, the shape UPGRADING.md actually writes. */ const INLINE_LINK = /(\]\()([^()\s]+)((?:[ \t]+(?:"[^"]*"|'[^']*'|\([^()]*\)))?[ \t]*\))/g -/** `[id]: dest` at the start of a line, optionally followed by a title. */ -const REFERENCE_DEFINITION = /^( {0,3}\[[^\]]+\]:[ \t]*)(\S+)([ \t]*.*)$/ +/** + * `[id]: dest` at the start of a line, optionally followed by a title. The line + * must end after the destination or after a CommonMark title (`"…"`, `'…'` or + * `(…)`) — otherwise prose shaped `[Note]: see the guide …` would have its first + * word rewritten into a blob URL. A non-matching line falls through to the + * inline-link pass, so any real link on it is still rewritten. + */ +const REFERENCE_DEFINITION = /^( {0,3}\[[^\]]+\]:[ \t]*)(\S+)((?:[ \t]+(?:"[^"]*"|'[^']*'|\([^()]*\)))?[ \t]*)$/ function rewriteLinksInText(text, base) { return text.replace(INLINE_LINK, (whole, open, destination, close) => { @@ -391,11 +433,21 @@ export function composeReleaseNotes({ // anchors lifted out of UPGRADING.md are dead here (#2250). Only this // section is rewritten: the curated notes file carries no relative-link // shapes today, and the generated changelog is already absolute. - const upgradingSection = extractUpgradingSection(upgradingText, tag) + // A MALFORMED section is an error for BOTH tag classes: publishing every older + // version's notes under this heading is worse than publishing none of them. + let upgradingSection = null + let upgradingMalformed = false + try { + upgradingSection = extractUpgradingSection(upgradingText, tag) + } catch (error) { + if (!(error instanceof MalformedUpgradingSectionError)) throw error + upgradingMalformed = true + errors.push(`compose-release-notes: ${error.message}`) + } if (upgradingSection) { sections.push(`## Breaking changes\n\n${rewriteRelativeLinks(upgradingSection, { repo, tag })}`) } else { - record(`UPGRADING.md has no "## ${tag}" section`) + if (!upgradingMalformed) record(`UPGRADING.md has no "## ${tag}" section`) sections.push( `## Breaking changes\n\nNo \`UPGRADING.md\` section was written for \`${tag}\` at tag time — ` + `read [UPGRADING.md](${upgradingUrl}) before upgrading.`, diff --git a/scripts/ci/compose-release-notes.test.mjs b/scripts/ci/compose-release-notes.test.mjs index 5979d62fc..c12c51a04 100644 --- a/scripts/ci/compose-release-notes.test.mjs +++ b/scripts/ci/compose-release-notes.test.mjs @@ -26,6 +26,7 @@ import { parseChecksum, parseArgs, rewriteRelativeLinks, + MalformedUpgradingSectionError, MAX_RELEASE_BODY_LENGTH, } from './compose-release-notes.mjs' @@ -282,20 +283,53 @@ test('a tag heading that only appears inside a fence is not a section start', () assert.equal(extractUpgradingSection(doc, RC_TAG), null) }) -test('an unterminated fence runs to the end of the document', () => { - const doc = [ - `## ${RC_TAG}`, - '', - '```bash', - '## looks like a heading', - '', - '## v0.2.0', - '', - 'older notes', - ].join('\n') - const section = extractUpgradingSection(doc, RC_TAG) - assert.ok(section.includes('## v0.2.0'), 'an unclosed fence swallows the rest, as a Markdown renderer would') - assert.ok(section.includes('older notes'), section) +// An unterminated fence would otherwise swallow every OLDER version's notes into +// the Breaking-changes heading, and the body-length guard cannot catch it because +// the whole of UPGRADING.md is far under the limit. The document is malformed, so +// the compose fails at tag time instead of publishing a wrong page. +const UNTERMINATED_FENCE = [ + '# Version notes', + '', + `## ${RC_TAG}`, + '', + '```bash', + '## looks like a heading', + '', + '## v0.2.0', + '', + 'older notes', + '', +].join('\n') + +test('an unterminated fence in the section is a malformed document, not a longer section', () => { + assert.throws( + () => extractUpgradingSection(UNTERMINATED_FENCE, RC_TAG), + (error) => { + assert.ok(error instanceof MalformedUpgradingSectionError, `wrong error type: ${error}`) + assert.ok(error.message.includes(RC_TAG), error.message) + assert.match(error.message, /unterminated fenced code block in the UPGRADING section/) + return true + }, + ) +}) + +test('a malformed UPGRADING section is an ERROR for an RC as well as a stable tag', () => { + for (const prerelease of [true, false]) { + const tag = RC_TAG + const { errors } = composeReleaseNotes({ + tag, + prerelease, + repo: REPO, + assetName: assetFor(tag), + checksumText: checksumFor(tag), + upgradingText: UNTERMINATED_FENCE, + notesText: NOTES, + generatedNotes: GENERATED, + }) + const malformed = errors.find((e) => e.includes('unterminated fenced code block in the UPGRADING section')) + assert.ok(malformed, `prerelease=${prerelease}: expected a hard failure, got ${JSON.stringify(errors)}`) + assert.ok(malformed.includes(tag), malformed) + } }) test('a closing fence must match the opening character and length', () => { @@ -611,6 +645,27 @@ test('the CLI exits non-zero and writes NOTHING when a stable tag is missing its assert.equal(result.body, null, 'no half-rendered page may reach --notes-file') }) +test('the CLI fails closed on a malformed UPGRADING section, naming it on the ::error line', () => { + const result = runCli( + (paths) => [ + '--tag', RC_TAG, + '--prerelease', 'true', + '--repo', REPO, + '--asset', assetFor(RC_TAG), + '--checksum-file', paths['checksum.sha256'], + '--upgrading', paths['UPGRADING.md'], + '--notes', paths['notes.md'], + '--generated-notes', paths['generated.json'], + ], + { ...cliFiles, 'UPGRADING.md': UNTERMINATED_FENCE }, + ) + assert.notEqual(result.status, 0, result.stderr) + assert.match(result.stderr, /::error::/) + assert.match(result.stderr, /unterminated fenced code block in the UPGRADING section/) + assert.ok(result.stderr.includes(RC_TAG), result.stderr) + assert.equal(result.body, null, 'a wrong page must never reach --notes-file') +}) + test('the CLI refuses a missing required option with exit 2', () => { const result = runCli(() => ['--tag', RC_TAG], {}) assert.equal(result.status, 2) @@ -687,6 +742,26 @@ test('a reference-style link definition gets the same treatment', () => { assert.equal(rewrite('[keep]: https://example.com/x'), '[keep]: https://example.com/x') }) +test('an angle-bracketed destination is rewritten inside its brackets', () => { + assert.equal(rewrite('[x]()'), `[x](<${LINK_BASE}/docs/x.md>)`) + assert.equal(rewrite('[x](<./docs/x.md#a-section>)'), `[x](<${LINK_BASE}/docs/x.md#a-section>)`) + assert.equal(rewrite('[x](<#anchor>)'), `[x](<${LINK_BASE}/UPGRADING.md#anchor>)`) + assert.equal(rewrite('[x]()'), '[x]()') + assert.equal(rewrite('[ref]: '), `[ref]: <${LINK_BASE}/docs/x.md>`) +}) + +test('prose shaped like a reference definition is left alone, but its inline links are not', () => { + assert.equal( + rewrite('[Note]: see [the guide](docs/x.md) before upgrading'), + `[Note]: see [the guide](${LINK_BASE}/docs/x.md) before upgrading`, + ) + assert.equal(rewrite('[Warning]: back up first, then run the migration'), '[Warning]: back up first, then run the migration') + // A real definition, with and without each CommonMark title form, still rewrites. + assert.equal(rewrite('[guide]: docs/x.md "The guide"'), `[guide]: ${LINK_BASE}/docs/x.md "The guide"`) + assert.equal(rewrite("[guide]: docs/x.md 'The guide'"), `[guide]: ${LINK_BASE}/docs/x.md 'The guide'`) + assert.equal(rewrite('[guide]: docs/x.md (The guide)'), `[guide]: ${LINK_BASE}/docs/x.md (The guide)`) +}) + test('the tag is percent-encoded exactly as the UPGRADING fallback link encodes it', () => { const oddTag = 'v1.0.0+build/1' assert.equal( From 6150eb1a5cc0dd718554c46f2f7143d09d5a3422 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 12:32:31 +0100 Subject: [PATCH 4/4] docs(release): record the link rewrite and fail-closed fence policy; name the three contract suites --- .github/workflows/ci-required.yml | 3 ++- docs/ops/RELEASE_TRUST_AND_DISTRIBUTION.md | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-required.yml b/.github/workflows/ci-required.yml index a9f0f2282..6219c9868 100644 --- a/.github/workflows/ci-required.yml +++ b/.github/workflows/ci-required.yml @@ -6,7 +6,8 @@ # # ci-required.yml PR/push/merge_group gate (this file) # ├── reusable-docs-governance.yml docs + golden-principles + ops governance -# ├── release-workflow-contract release-desktop dispatch hardening gate (#1795/#1806) +# ├── release-workflow-contract release contract suites: dispatch hardening (#1795/#1806), +# │ release cache trust, release notes composer (#2234/#2250) # ├── reusable-backend-architecture.yml architecture boundary tests # ├── reusable-backend-unit.yml domain / application / CLI unit tests (Ubuntu + Windows) # ├── reusable-api-integration.yml API integration tests (Ubuntu + Windows) diff --git a/docs/ops/RELEASE_TRUST_AND_DISTRIBUTION.md b/docs/ops/RELEASE_TRUST_AND_DISTRIBUTION.md index f6c29b788..bea15198d 100644 --- a/docs/ops/RELEASE_TRUST_AND_DISTRIBUTION.md +++ b/docs/ops/RELEASE_TRUST_AND_DISTRIBUTION.md @@ -73,7 +73,12 @@ after publish. `scripts/ci/compose-release-notes.mjs` renders the body instead, that ships inside the ZIP, and the `Get-FileHash` line for checking the download. For a prerelease this block also carries a one-line release-candidate banner. The button is always the first line of the page. 2. **`## Breaking changes`** — lifted from the tag's own section in **`UPGRADING.md`** (`## …`), so the section - cannot be forgotten at tag time. + cannot be forgotten at tag time. A release body is not a file in the tree, so the lift rewrites the section's bare + anchors (against `UPGRADING.md`) and relative paths into `blob/` URLs, leaving fenced blocks, inline code spans, + scheme-bearing and root-relative destinations as written. The lift is fence-aware and **fails closed**: a `#`/`##` + line inside a fenced block neither starts nor ends the section, and a fence still open at the end of the document is + a malformed `UPGRADING.md` that fails the compose for a release candidate as well as a stable tag — rather than + publishing every older version's notes under this heading. 3. **`## Highlights`** — the curated **`docs/releases/notes/.md`**, written by the pre-tag docs PR. 4. **`## What's changed`** — the `releases/generate-notes` body, grouped through `.github/release.yml` and carrying its full-changelog compare link.