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
6 changes: 5 additions & 1 deletion .github/workflows/ci-required.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -134,6 +135,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
Expand Down
7 changes: 6 additions & 1 deletion docs/ops/RELEASE_TRUST_AND_DISTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`** (`## <tag> …`), 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/<tag>` 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/<tag>.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.
Expand Down
227 changes: 221 additions & 6 deletions scripts/ci/compose-release-notes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
//
Expand Down Expand Up @@ -92,18 +97,80 @@ 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
* 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 `## <tag> …` 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 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
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} `)) {
Expand All @@ -113,14 +180,148 @@ 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)
}
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
}

/**
* 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.
*
* CommonMark's angle-bracket form (`](<docs/x.md>)`) is unwrapped, rewritten and
* re-wrapped: concatenating `<docs/x.md>` onto the blob base verbatim would
* publish a dead `.../blob/<tag>/<docs/x.md>` 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
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. 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) => {
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()
Expand Down Expand Up @@ -228,11 +429,25 @@ export function composeReleaseNotes({
)

// --- 2. Breaking changes, from the tag's UPGRADING section ---------------
const upgradingSection = extractUpgradingSection(upgradingText, tag)
// 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.
// 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${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.`,
Expand Down
Loading
Loading