|
16 | 16 | // The pins live in `node scripts/release-pending-publish.mjs --self-test`, |
17 | 17 | // which lint.yml runs: an add -> version -> revert -> re-cut fixture through |
18 | 18 | // these two functions. |
| 19 | +// |
| 20 | +// ## The stable cut folds its prerelease sections (#26079, ruling A′) |
| 21 | +// |
| 22 | +// Leaving pre mode (`changeset pre exit`) and versioning applies every |
| 23 | +// changeset the cycle's prereleases consumed a second time, so the stable |
| 24 | +// `## X.Y.Z` section repeats each `## X.Y.Z-next.N` section below it: 18.0.0's |
| 25 | +// cut left spec with 4,076 duplicated long lines. On that cut the prerelease |
| 26 | +// sections are FOLDED into the stable section instead of copied: the stable |
| 27 | +// section is written whole, as the stock writer always writes it, and each |
| 28 | +// prerelease section of the cycle is dropped once every entry it carries is |
| 29 | +// found in the new stable section. |
| 30 | +// |
| 31 | +// What may fold is fixed by the file as it stood before the cut, never by a |
| 32 | +// version guess: only a CHANGELOG whose top section was a prerelease when |
| 33 | +// `changeset version` started and that now opens with a new stable section, |
| 34 | +// and in it only the prerelease sections between that stable section and the |
| 35 | +// next stable one below. Every stable section, and every older cycle's |
| 36 | +// prerelease sections, is kept byte for byte -- existing text is not rewritten |
| 37 | +// (#25919 A 「存量不横扫」). A prerelease section holding an entry the stable |
| 38 | +// section does not reproduce (amended in place by a docs PR, say) is kept, |
| 39 | +// with a `::warning::` naming it. An entry is compared without its commit |
| 40 | +// prefix, and dependency-bump entries (`Updated dependencies` and the |
| 41 | +// `pkg@X.Y.Z-next.N` lines under them) are superseded by the stable section's |
| 42 | +// own, so neither holds a section back. |
| 43 | +// |
| 44 | +// The generator API hands out one release line at a time and nothing of it |
| 45 | +// runs after the files are written, so the fold is armed on the first line of |
| 46 | +// an exit-mode cut (`.changeset/pre.json` with `"mode": "exit"`, which the |
| 47 | +// version pass itself deletes) and runs when that process exits 0. The ruling |
| 48 | +// keeps `CHANGELOG.md` in every package's `files`: the tarball copy is still |
| 49 | +// the text an upgrading agent greps (AGENTS.md, Documentation Guardrails). |
| 50 | +// Its pins sit beside the attribution's, in the same CI-run self-test. |
19 | 51 |
|
| 52 | +import { closeSync, openSync, readFileSync, readSync, writeFileSync } from 'node:fs'; |
| 53 | +import { execFileSync } from 'node:child_process'; |
| 54 | +import { join } from 'node:path'; |
20 | 55 | import stock from '@changesets/cli/changelog'; |
21 | 56 | import { createChangelogAttribution } from './release-pending-publish.mjs'; |
22 | 57 |
|
23 | 58 | /** The two functions `changeset version` calls, attributing from the repository at `cwd`. */ |
24 | 59 | export function changelogFunctions(options = {}) { |
25 | 60 | const attribute = createChangelogAttribution({ cwd: process.cwd(), ...options }); |
26 | 61 | const attributed = (changeset) => ({ ...changeset, commit: attribute(changeset) }); |
| 62 | + let armed = false; |
| 63 | + const arm = () => { |
| 64 | + if (armed) return; |
| 65 | + armed = true; |
| 66 | + armPrereleaseFold({ cwd: options.cwd ?? process.cwd(), log: options.log }); |
| 67 | + }; |
27 | 68 | return { |
28 | | - getReleaseLine: (changeset, type, opts) => stock.getReleaseLine(attributed(changeset), type, opts), |
29 | | - getDependencyReleaseLine: (changesets, dependenciesUpdated, opts) => |
30 | | - stock.getDependencyReleaseLine(changesets.map(attributed), dependenciesUpdated, opts), |
| 69 | + getReleaseLine: (changeset, type, opts) => { |
| 70 | + arm(); |
| 71 | + return stock.getReleaseLine(attributed(changeset), type, opts); |
| 72 | + }, |
| 73 | + getDependencyReleaseLine: (changesets, dependenciesUpdated, opts) => { |
| 74 | + arm(); |
| 75 | + return stock.getDependencyReleaseLine(changesets.map(attributed), dependenciesUpdated, opts); |
| 76 | + }, |
31 | 77 | }; |
32 | 78 | } |
33 | 79 |
|
34 | 80 | export default changelogFunctions(); |
| 81 | + |
| 82 | +// ───────────────────────────────────────────────────────────────────────────── |
| 83 | +// The fold |
| 84 | +// ───────────────────────────────────────────────────────────────────────────── |
| 85 | + |
| 86 | +const HEADING = /^## (\S+)[ \t]*$/gm; |
| 87 | +const STABLE = /^\d+\.\d+\.\d+$/; |
| 88 | +const PRERELEASE = /^\d+\.\d+\.\d+-[0-9A-Za-z.-]+$/; |
| 89 | +const COMMIT_PREFIX = /^[0-9a-f]{7,40}: /; |
| 90 | + |
| 91 | +/** Every `## VERSION` section of a CHANGELOG: its version and [start, end) offsets. */ |
| 92 | +export function versionSections(text) { |
| 93 | + const heads = [...text.matchAll(HEADING)].filter((m) => STABLE.test(m[1]) || PRERELEASE.test(m[1])); |
| 94 | + return heads.map((m, i) => ({ version: m[1], start: m.index, end: i + 1 < heads.length ? heads[i + 1].index : text.length })); |
| 95 | +} |
| 96 | + |
| 97 | +/** A section's changeset entries, each without its commit prefix; dependency bumps are left out. */ |
| 98 | +function changesetEntries(body) { |
| 99 | + const entries = []; |
| 100 | + let current = null; |
| 101 | + for (const line of body.split('\n')) { |
| 102 | + if (line.startsWith('- ')) { |
| 103 | + current = [line.slice(2).replace(COMMIT_PREFIX, '')]; |
| 104 | + entries.push(current); |
| 105 | + } else if (current && (line.startsWith(' ') || line === '')) { |
| 106 | + current.push(line); |
| 107 | + } else { |
| 108 | + current = null; |
| 109 | + } |
| 110 | + } |
| 111 | + return entries |
| 112 | + .map((lines) => lines.join('\n').trimEnd()) |
| 113 | + .filter((entry) => !entry.startsWith('Updated dependencies')); |
| 114 | +} |
| 115 | + |
| 116 | +/** |
| 117 | + * Fold one CHANGELOG after a stable cut. `previousTop` is the version its top |
| 118 | + * section carried before the cut; nothing folds unless that was a prerelease |
| 119 | + * and the text now opens with a stable section directly above it. |
| 120 | + */ |
| 121 | +export function foldPrereleaseSections(text, previousTop) { |
| 122 | + const unchanged = (reason) => ({ text, folded: [], kept: [], reason }); |
| 123 | + if (!PRERELEASE.test(previousTop ?? '')) return unchanged('the top section before the cut was not a prerelease'); |
| 124 | + const sections = versionSections(text); |
| 125 | + if (sections.length < 2 || !STABLE.test(sections[0].version)) return unchanged('the file does not open with a stable section'); |
| 126 | + if (sections[1].version !== previousTop) return unchanged(`the section under ${sections[0].version} is not ${previousTop}, the top before the cut`); |
| 127 | + const stable = new Set(changesetEntries(text.slice(sections[0].start, sections[0].end))); |
| 128 | + const folded = []; |
| 129 | + const kept = []; |
| 130 | + let out = text.slice(0, sections[1].start); |
| 131 | + let i = 1; |
| 132 | + for (; i < sections.length && PRERELEASE.test(sections[i].version); i += 1) { |
| 133 | + const s = sections[i]; |
| 134 | + const missing = changesetEntries(text.slice(s.start, s.end)).filter((entry) => !stable.has(entry)); |
| 135 | + if (missing.length === 0) { |
| 136 | + folded.push(s.version); |
| 137 | + } else { |
| 138 | + kept.push({ version: s.version, missing }); |
| 139 | + out += text.slice(s.start, s.end); |
| 140 | + } |
| 141 | + } |
| 142 | + out += text.slice(i < sections.length ? sections[i].start : text.length); |
| 143 | + return { text: out, folded, kept, reason: null }; |
| 144 | +} |
| 145 | + |
| 146 | +/** The first `## VERSION` of a file, read from its head only (spec's CHANGELOG is 10 MB). */ |
| 147 | +function topVersion(path) { |
| 148 | + const fd = openSync(path, 'r'); |
| 149 | + try { |
| 150 | + const buf = Buffer.alloc(64 * 1024); |
| 151 | + const n = readSync(fd, buf, 0, buf.length, 0); |
| 152 | + return versionSections(buf.subarray(0, n).toString('utf8'))[0]?.version ?? null; |
| 153 | + } finally { |
| 154 | + closeSync(fd); |
| 155 | + } |
| 156 | +} |
| 157 | + |
| 158 | +/** |
| 159 | + * On an exit-mode cut, snapshot the tracked CHANGELOGs whose top section is a |
| 160 | + * prerelease, and hand back the function that folds them once written. Null |
| 161 | + * when this is not a stable cut out of pre mode. |
| 162 | + */ |
| 163 | +export function prepareFold({ cwd, log = console.log }) { |
| 164 | + let root; |
| 165 | + let mode; |
| 166 | + try { |
| 167 | + root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); |
| 168 | + mode = JSON.parse(readFileSync(join(root, '.changeset', 'pre.json'), 'utf8')).mode; |
| 169 | + } catch { |
| 170 | + return null; |
| 171 | + } |
| 172 | + if (mode !== 'exit') return null; |
| 173 | + const warn = (text) => log(`::warning::changeset-changelog: ${text}`); |
| 174 | + const tops = new Map(); |
| 175 | + const listed = execFileSync('git', ['ls-files', '-z', '--', '*CHANGELOG.md'], { cwd: root, encoding: 'utf8' }); |
| 176 | + for (const rel of listed.split('\0').filter(Boolean)) { |
| 177 | + try { |
| 178 | + const top = topVersion(join(root, rel)); |
| 179 | + if (top && PRERELEASE.test(top)) tops.set(rel, top); |
| 180 | + } catch (err) { |
| 181 | + warn(`${rel} could not be read before the cut (${err instanceof Error ? err.message : err}); its prerelease sections stay.`); |
| 182 | + } |
| 183 | + } |
| 184 | + const apply = () => { |
| 185 | + let files = 0; |
| 186 | + let sections = 0; |
| 187 | + for (const [rel, previousTop] of tops) { |
| 188 | + try { |
| 189 | + const path = join(root, rel); |
| 190 | + const r = foldPrereleaseSections(readFileSync(path, 'utf8'), previousTop); |
| 191 | + for (const k of r.kept) { |
| 192 | + warn(`${rel}: ${k.version} holds ${k.missing.length} entr${k.missing.length === 1 ? 'y' : 'ies'} the stable section does not reproduce, so it stays.`); |
| 193 | + } |
| 194 | + if (r.folded.length === 0) continue; |
| 195 | + writeFileSync(path, r.text); |
| 196 | + files += 1; |
| 197 | + sections += r.folded.length; |
| 198 | + } catch (err) { |
| 199 | + warn(`${rel} could not be folded (${err instanceof Error ? err.message : err}); it stays as the version pass wrote it.`); |
| 200 | + } |
| 201 | + } |
| 202 | + log(`changeset-changelog: the stable cut folded ${sections} prerelease section(s) in ${files} CHANGELOG(s) into their stable section.`); |
| 203 | + }; |
| 204 | + return { root, tops, apply }; |
| 205 | +} |
| 206 | + |
| 207 | +function armPrereleaseFold({ cwd, log }) { |
| 208 | + const fold = prepareFold({ cwd, log }); |
| 209 | + if (fold === null || fold.tops.size === 0) return; |
| 210 | + // `changeset version` writes the files after the last line is generated, so |
| 211 | + // the fold runs when the version pass ends, and only when it succeeded. |
| 212 | + process.once('exit', (code) => { |
| 213 | + if (code === 0) fold.apply(); |
| 214 | + }); |
| 215 | +} |
0 commit comments