chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env node
|
||||
// Generate (or inject) the `### 🙌 Contributors` table for a CHANGELOG version section.
|
||||
//
|
||||
// WHY: every version's CHANGELOG `## [vX.Y.Z]` section MUST end with a `### 🙌 Contributors`
|
||||
// table (the convention across every prior version). v3.8.43 shipped without it (a real miss the
|
||||
// owner caught) because it was assembled by hand. This makes it reproducible + accurate.
|
||||
//
|
||||
// A naive `@handle` scan mis-assigns rollup PRs — a maintenance bullet lists many PRs under one
|
||||
// `— thanks @X`, and a flat scan would credit every handle on the line with all of them. This
|
||||
// parses each `([#refs] — thanks @X / @Y)` PARENTHETICAL GROUP and assigns that group's refs only
|
||||
// to that group's handles (crediting is per-parenthetical, matching how bullets are written).
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/release/gen-contributors.mjs <version> # print the table
|
||||
// node scripts/release/gen-contributors.mjs <version> --inject # insert/replace it in CHANGELOG.md
|
||||
//
|
||||
// Exit codes: 0 ok · 2 version section not found · 3 nothing to inject over.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
// Handles that are package names / code refs / scopes, never people. Extend as needed.
|
||||
export const NOISE_HANDLES = new Set([
|
||||
"toon-format",
|
||||
"dnd-kit",
|
||||
"om-usage",
|
||||
"anthropic-ai",
|
||||
"huggingface",
|
||||
"oven",
|
||||
"latest",
|
||||
"next",
|
||||
"types",
|
||||
]);
|
||||
|
||||
const MAINTAINER = "diegosouzapw";
|
||||
|
||||
/** Extract the `## [version]` … up to the next `## [` section body (exclusive of the next header). */
|
||||
export function extractVersionSection(changelog, version) {
|
||||
const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const startRe = new RegExp(`^## \\[${esc}\\][^\\n]*$`, "m");
|
||||
const sm = changelog.match(startRe);
|
||||
if (!sm) return null;
|
||||
const bodyStart = sm.index + sm[0].length;
|
||||
const rest = changelog.slice(bodyStart);
|
||||
const nextIdx = rest.search(/\n## \[/);
|
||||
return nextIdx === -1 ? rest : rest.slice(0, nextIdx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse contributor → set of ref numbers from a version section body.
|
||||
* Rules (in order, per bullet line starting with "- "):
|
||||
* 1. Parenthetical groups containing "thanks": refs in the group → handles in the group.
|
||||
* 2. A "thanks @X" NOT inside such a group (direct-commit trailing credit): the last ref before
|
||||
* it on the line (if any) → the handles.
|
||||
* 3. "Extracted from [#N] by [@X]": N → X.
|
||||
* Excludes NOISE_HANDLES and the maintainer (returned separately by caller).
|
||||
*/
|
||||
export function parseContributors(sectionText) {
|
||||
const agg = new Map(); // handle -> Set(refs)
|
||||
const add = (handle, refs) => {
|
||||
if (NOISE_HANDLES.has(handle) || handle === MAINTAINER) return;
|
||||
if (!agg.has(handle)) agg.set(handle, new Set());
|
||||
for (const r of refs) agg.get(handle).add(r);
|
||||
};
|
||||
const handlesIn = (s) => [...s.matchAll(/@([A-Za-z0-9_-]+)/g)].map((m) => m[1]);
|
||||
const refsIn = (s) => [...s.matchAll(/#(\d+)/g)].map((m) => Number(m[1]));
|
||||
|
||||
for (const raw of sectionText.split("\n")) {
|
||||
if (!raw.startsWith("- ")) continue;
|
||||
// Collapse markdown links so parenthetical groups aren't broken by the URL's own parens:
|
||||
// [#5720](https://…/pull/5720) → #5720 · [@pizzav-xyz](https://…) → @pizzav-xyz
|
||||
const line = raw
|
||||
.replace(/\[#(\d+)\]\([^)]*\)/g, "#$1")
|
||||
.replace(/\[@([A-Za-z0-9_-]+)\]\([^)]*\)/g, "@$1");
|
||||
const usedSpans = [];
|
||||
|
||||
// (1) parenthetical groups with "thanks"
|
||||
for (const g of line.matchAll(/\(([^()]*thanks[^()]*)\)/g)) {
|
||||
const inner = g[1];
|
||||
const refs = refsIn(inner);
|
||||
for (const th of inner.matchAll(/thanks\s+((?:@[A-Za-z0-9_-]+(?:\s*\/\s*)?)+)/g)) {
|
||||
for (const h of handlesIn(th[1])) add(h, refs);
|
||||
}
|
||||
usedSpans.push([g.index, g.index + g[0].length]);
|
||||
}
|
||||
|
||||
// (2) trailing "— thanks @X" outside any used parenthetical (direct commits)
|
||||
for (const th of line.matchAll(/thanks\s+((?:@[A-Za-z0-9_-]+(?:\s*\/\s*)?)+)/g)) {
|
||||
const inGroup = usedSpans.some(([s, e]) => th.index >= s && th.index < e);
|
||||
if (inGroup) continue;
|
||||
const before = line.slice(0, th.index);
|
||||
const refsBefore = refsIn(before);
|
||||
const refs = refsBefore.length ? [refsBefore[refsBefore.length - 1]] : [];
|
||||
for (const h of handlesIn(th[1])) add(h, refs);
|
||||
}
|
||||
|
||||
// (3) "Extracted from #N by @X" (links already collapsed by the preprocessing above)
|
||||
for (const em of line.matchAll(/[Ee]xtracted from #(\d+)\s+by\s+@([A-Za-z0-9_-]+)/g)) {
|
||||
add(em[2], [Number(em[1])]);
|
||||
}
|
||||
}
|
||||
return agg;
|
||||
}
|
||||
|
||||
export function renderContributors(version, agg, maintainerNote = "maintainer") {
|
||||
const fmt = (set) =>
|
||||
set.size
|
||||
? [...set]
|
||||
.sort((a, b) => a - b)
|
||||
.map((n) => `#${n}`)
|
||||
.join(", ")
|
||||
: "direct commit / report";
|
||||
const rows = [...agg.entries()].sort((a, b) =>
|
||||
a[0].toLowerCase().localeCompare(b[0].toLowerCase())
|
||||
);
|
||||
const lines = [
|
||||
"### 🙌 Contributors",
|
||||
"",
|
||||
`Thanks to everyone whose work landed in v${version}:`,
|
||||
"",
|
||||
"| Contributor | PRs / Issues |",
|
||||
"| --- | --- |",
|
||||
];
|
||||
for (const [h, refs] of rows) {
|
||||
lines.push(`| [@${h}](https://github.com/${h}) | ${fmt(refs)} |`);
|
||||
}
|
||||
lines.push(`| [@${MAINTAINER}](https://github.com/${MAINTAINER}) | ${maintainerNote} |`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Insert or replace the Contributors section inside the version block, before its closing `---`. */
|
||||
export function injectContributors(changelog, version, table) {
|
||||
const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const startRe = new RegExp(`^## \\[${esc}\\][^\\n]*$`, "m");
|
||||
const sm = changelog.match(startRe);
|
||||
if (!sm) return null;
|
||||
const headerEnd = sm.index + sm[0].length;
|
||||
const rest = changelog.slice(headerEnd);
|
||||
const nextIdx = rest.search(/\n## \[/);
|
||||
const bodyEnd = nextIdx === -1 ? changelog.length : headerEnd + nextIdx;
|
||||
let body = changelog.slice(headerEnd, bodyEnd);
|
||||
// strip an existing Contributors section (idempotent re-run)
|
||||
body = body.replace(/\n### 🙌 Contributors[\s\S]*?(?=\n---\n|$)/, "\n");
|
||||
// insert before the trailing `---` (or append if none)
|
||||
const idx = body.lastIndexOf("\n---");
|
||||
const insertion = `\n${table}\n`;
|
||||
body = idx >= 0 ? body.slice(0, idx) + insertion + body.slice(idx) : `${body}${insertion}\n---\n`;
|
||||
return changelog.slice(0, headerEnd) + body + changelog.slice(bodyEnd);
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const version = argv[0];
|
||||
const inject = argv.includes("--inject");
|
||||
if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
|
||||
process.stderr.write("usage: gen-contributors.mjs <version> [--inject]\n");
|
||||
process.exit(1);
|
||||
}
|
||||
const clPath = path.join(ROOT, "CHANGELOG.md");
|
||||
const changelog = fs.readFileSync(clPath, "utf8");
|
||||
const section = extractVersionSection(changelog, version);
|
||||
if (section == null) {
|
||||
process.stderr.write(`No [${version}] section in CHANGELOG.md\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
const agg = parseContributors(section);
|
||||
const table = renderContributors(version, agg);
|
||||
if (!inject) {
|
||||
process.stdout.write(table + "\n");
|
||||
return;
|
||||
}
|
||||
const next = injectContributors(changelog, version, table);
|
||||
if (next == null) {
|
||||
process.stderr.write(`Could not locate [${version}] block for injection\n`);
|
||||
process.exit(3);
|
||||
}
|
||||
fs.writeFileSync(clPath, next);
|
||||
process.stderr.write(`✓ Injected ${agg.size} external contributor(s) into [${version}]\n`);
|
||||
}
|
||||
|
||||
// direct-run guard (importable for tests)
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main(process.argv.slice(2));
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
// Reconciliation helper: list non-merge commits since the last tag whose PR/issue ref is NOT
|
||||
// represented in the current version's CHANGELOG section (or [Unreleased]).
|
||||
//
|
||||
// WHY: during the cycle, PRs merge into release/** and some land WITHOUT a CHANGELOG bullet, so
|
||||
// /generate-release reconciliation has to rediscover them by hand (v3.8.43: 123 of 176 commits had
|
||||
// no bullet). This surfaces exactly that gap in seconds — maintainer-side, non-blocking, run it at
|
||||
// reconciliation (Phase 0a) so the release CHANGELOG is complete before the PR opens.
|
||||
//
|
||||
// A commit is "covered" iff ANY `#N` in its subject appears anywhere in the CHANGELOG scan window
|
||||
// (the version section + [Unreleased]) — matching on issue OR PR number, since a bullet may cite
|
||||
// either. Internal commits (chore/ci/test/refactor) are listed under "rollup candidates" so the
|
||||
// maintainer can consolidate rather than write one bullet each.
|
||||
//
|
||||
// Usage: node scripts/release/list-uncovered-commits.mjs [--json]
|
||||
// Exit: 0 always (advisory). Prints a report to stdout.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const git = (args) => execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim();
|
||||
|
||||
const ROLLUP_TYPES = new Set(["chore", "ci", "test", "refactor", "build", "docs", "style"]);
|
||||
|
||||
export function refsOf(subject) {
|
||||
return [...subject.matchAll(/#(\d+)/g)].map((m) => Number(m[1]));
|
||||
}
|
||||
|
||||
export function typeOf(subject) {
|
||||
const m = subject.match(/^([a-z]+)(\(|:|!)/);
|
||||
return m ? m[1] : "other";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{hash:string, subject:string}[]} commits
|
||||
* @param {Set<number>} changelogRefs every #N present in the CHANGELOG scan window
|
||||
* @returns {{covered:number, uncovered:{hash,subject,refs,type,rollup}[]}}
|
||||
*/
|
||||
export function computeUncovered(commits, changelogRefs) {
|
||||
const uncovered = [];
|
||||
let covered = 0;
|
||||
for (const c of commits) {
|
||||
const refs = refsOf(c.subject);
|
||||
const isCovered = refs.length > 0 && refs.some((r) => changelogRefs.has(r));
|
||||
if (isCovered) {
|
||||
covered++;
|
||||
} else {
|
||||
const type = typeOf(c.subject);
|
||||
uncovered.push({ ...c, refs, type, rollup: ROLLUP_TYPES.has(type) });
|
||||
}
|
||||
}
|
||||
return { covered, uncovered };
|
||||
}
|
||||
|
||||
/** Read every #N in the version's CHANGELOG section + the [Unreleased] section. */
|
||||
export function changelogRefWindow(changelog, version) {
|
||||
const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
// From [Unreleased] up to (but excluding) the version-after-this one.
|
||||
const startRe = /^## \[Unreleased\]/m;
|
||||
const s = changelog.match(startRe);
|
||||
const from = s ? s.index : 0;
|
||||
// find the header AFTER the target version
|
||||
const verRe = new RegExp(`^## \\[${esc}\\]`, "m");
|
||||
const vm = changelog.slice(from).match(verRe);
|
||||
const afterVersionStart = vm ? from + vm.index + vm[0].length : from;
|
||||
const rest = changelog.slice(afterVersionStart);
|
||||
const nextIdx = rest.search(/\n## \[/);
|
||||
const to = nextIdx === -1 ? changelog.length : afterVersionStart + nextIdx;
|
||||
const window = changelog.slice(from, to);
|
||||
return new Set([...window.matchAll(/#(\d+)/g)].map((m) => Number(m[1])));
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const jsonOut = argv.includes("--json");
|
||||
const lastTag = git(["describe", "--tags", "--abbrev=0"]);
|
||||
const version = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
|
||||
const log = git(["log", "--no-merges", `${lastTag}..HEAD`, "--pretty=format:%h%x09%s"]);
|
||||
const commits = log
|
||||
? log.split("\n").map((l) => {
|
||||
const [hash, subject] = l.split("\t");
|
||||
return { hash, subject };
|
||||
})
|
||||
: [];
|
||||
const changelog = fs.readFileSync(path.join(ROOT, "CHANGELOG.md"), "utf8");
|
||||
const refs = changelogRefWindow(changelog, version);
|
||||
const { covered, uncovered } = computeUncovered(commits, refs);
|
||||
|
||||
if (jsonOut) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({ version, lastTag, total: commits.length, covered, uncovered }, null, 2) +
|
||||
"\n"
|
||||
);
|
||||
return;
|
||||
}
|
||||
const bulletsWorthy = uncovered.filter((c) => !c.rollup);
|
||||
const rollupCandidates = uncovered.filter((c) => c.rollup);
|
||||
process.stdout.write(`# Uncovered-commit reconciliation — v${version} (${lastTag}..HEAD)\n\n`);
|
||||
process.stdout.write(
|
||||
`Commits: ${commits.length} · covered: ${covered} · uncovered: ${uncovered.length}\n\n`
|
||||
);
|
||||
process.stdout.write(
|
||||
`## Needs a bullet (feat/fix/other — user-facing) — ${bulletsWorthy.length}\n`
|
||||
);
|
||||
for (const c of bulletsWorthy) process.stdout.write(`- ${c.hash} ${c.subject}\n`);
|
||||
process.stdout.write(
|
||||
`\n## Rollup candidates (chore/ci/test/refactor/docs) — ${rollupCandidates.length}\n`
|
||||
);
|
||||
for (const c of rollupCandidates) process.stdout.write(`- ${c.hash} ${c.subject}\n`);
|
||||
process.stdout.write(
|
||||
`\n> Advisory. Add a bullet for each user-facing item; consolidate rollup candidates into a few Maintenance bullets (list their PR numbers).\n`
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main(process.argv.slice(2));
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function sectionRange(text, ver, nextVer) {
|
||||
const lines = text.split("\n");
|
||||
const start = lines.findIndex((l) => l.startsWith(`## [${ver}]`));
|
||||
if (start < 0) return null;
|
||||
let end = lines.findIndex((l, i) => i > start && l.startsWith(`## [${nextVer}]`));
|
||||
if (end < 0) end = lines.length;
|
||||
return { lines, start, end };
|
||||
}
|
||||
|
||||
// Replace the [ver] section in every docs/i18n/<loc>/CHANGELOG.md with the root one.
|
||||
// If a mirror lacks the section, insert it before [nextVer]. Returns count updated.
|
||||
export function syncChangelogSection(root, ver, nextVer) {
|
||||
const rootCl = fs.readFileSync(path.join(root, "CHANGELOG.md"), "utf8");
|
||||
const r = sectionRange(rootCl, ver, nextVer);
|
||||
if (!r) throw new Error(`root CHANGELOG missing [${ver}]`);
|
||||
const block = r.lines.slice(r.start, r.end).join("\n");
|
||||
const i18nDir = path.join(root, "docs/i18n");
|
||||
if (!fs.existsSync(i18nDir)) return 0;
|
||||
let n = 0;
|
||||
for (const loc of fs.readdirSync(i18nDir)) {
|
||||
const fp = path.join(i18nDir, loc, "CHANGELOG.md");
|
||||
if (!fs.existsSync(fp)) continue;
|
||||
const txt = fs.readFileSync(fp, "utf8");
|
||||
const m = sectionRange(txt, ver, nextVer);
|
||||
let next;
|
||||
if (m) {
|
||||
next =
|
||||
m.lines.slice(0, m.start).join("\n") +
|
||||
"\n" +
|
||||
block +
|
||||
"\n" +
|
||||
m.lines.slice(m.end).join("\n");
|
||||
} else {
|
||||
const idx = txt.indexOf(`## [${nextVer}]`);
|
||||
if (idx < 0) continue;
|
||||
next = txt.slice(0, idx) + block + "\n\n" + txt.slice(idx);
|
||||
}
|
||||
if (next !== txt) {
|
||||
fs.writeFileSync(fp, next);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const [ver, nextVer] = process.argv.slice(2);
|
||||
if (!ver || !nextVer) {
|
||||
console.error("usage: sync-changelog-i18n.mjs <ver> <nextVer>");
|
||||
process.exit(2);
|
||||
}
|
||||
const n = syncChangelogSection(process.cwd(), ver, nextVer);
|
||||
console.log(`[sync-changelog-i18n] updated ${n} mirror(s) for [${ver}]`);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/release/sync-next-cycle.mjs
|
||||
//
|
||||
// Parallel-cycle sync-back (generate-release Phase 5, step 20).
|
||||
// Merges origin/main (which carries the just-shipped release's closing fixes +
|
||||
// finalized CHANGELOG) into the live next-cycle branch release/v<NEXT> that was
|
||||
// cut at the freeze (Phase 0a.0b) — resolving CHANGELOG.md with the anti-eat
|
||||
// protocol: main's CHANGELOG wins VERBATIM, and the next cycle's own
|
||||
// `## [<NEXT>] — TBD` section (with any bullets it already accumulated) is
|
||||
// re-inserted on top. Design: _tasks/release-flow/2026-07-04_proposta-ciclo-paralelo-v2.md
|
||||
//
|
||||
// Usage: node scripts/release/sync-next-cycle.mjs <nextVersion> e.g. 3.8.45
|
||||
//
|
||||
// Idempotent: safe to re-run after resolving any conflicts it could not
|
||||
// auto-resolve (it detects an in-progress merge in its worktree and resumes).
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
/**
|
||||
* Pure: insert (or replace) the next cycle's section into main's CHANGELOG.
|
||||
* - `mainChangelog`: the full CHANGELOG.md content from origin/main (wins verbatim).
|
||||
* - `nextSection`: the `## [<next>] — …` section text captured from the cycle
|
||||
* branch BEFORE the merge (header line through the line before the next `## [`
|
||||
* heading), or null/empty when the cycle has no section yet (a fresh `— TBD`
|
||||
* placeholder is synthesized).
|
||||
* The section lands right after the `## [Unreleased]` block (mirroring the
|
||||
* layout every cycle-open produces), before the first `## [x.y.z]` heading.
|
||||
*/
|
||||
export function insertNextSection(mainChangelog, nextSection, nextVersion) {
|
||||
const lines = mainChangelog.split("\n");
|
||||
const isVersionHeading = (l) => /^## \[\d+\.\d+\.\d+\]/.test(l);
|
||||
|
||||
// Drop any pre-existing copy of the next section from main's content (it
|
||||
// normally has none — main only learns about <next> at the NEXT release).
|
||||
const startIdx = lines.findIndex((l) => l.startsWith(`## [${nextVersion}]`));
|
||||
if (startIdx !== -1) {
|
||||
let endIdx = lines.length;
|
||||
for (let i = startIdx + 1; i < lines.length; i++) {
|
||||
if (isVersionHeading(lines[i]) || lines[i].startsWith("## [Unreleased]")) {
|
||||
endIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
lines.splice(startIdx, endIdx - startIdx);
|
||||
}
|
||||
|
||||
const section =
|
||||
nextSection && nextSection.trim().length > 0
|
||||
? nextSection.replace(/\s+$/, "")
|
||||
: `## [${nextVersion}] — TBD`;
|
||||
|
||||
// Insert before the first released-version heading.
|
||||
const firstVersionIdx = lines.findIndex(isVersionHeading);
|
||||
const insertAt = firstVersionIdx === -1 ? lines.length : firstVersionIdx;
|
||||
lines.splice(insertAt, 0, ...section.split("\n"), "", "---", "");
|
||||
|
||||
// Collapse accidental duplicate separators introduced by the splice.
|
||||
return lines
|
||||
.join("\n")
|
||||
.replace(/\n---\n\n---\n/g, "\n---\n")
|
||||
.replace(/\n{4,}/g, "\n\n\n");
|
||||
}
|
||||
|
||||
/** Pure: the version whose `## [x.y.z]` heading follows `version`'s section (or null). */
|
||||
export function versionAfter(changelog, version) {
|
||||
const lines = changelog.split("\n");
|
||||
const start = lines.findIndex((l) => l.startsWith(`## [${version}]`));
|
||||
if (start === -1) return null;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
const m = lines[i].match(/^## \[(\d+\.\d+\.\d+)\]/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Pure: extract the `## [<version>]` section (header included, next heading excluded). */
|
||||
export function extractSection(changelog, version) {
|
||||
const lines = changelog.split("\n");
|
||||
const start = lines.findIndex((l) => l.startsWith(`## [${version}]`));
|
||||
if (start === -1) return null;
|
||||
let end = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (/^## \[/.test(lines[i])) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Trim a trailing `---` separator so re-insertion controls its own framing.
|
||||
let sectionLines = lines.slice(start, end);
|
||||
while (
|
||||
sectionLines.length &&
|
||||
(sectionLines[sectionLines.length - 1].trim() === "---" ||
|
||||
sectionLines[sectionLines.length - 1].trim() === "")
|
||||
) {
|
||||
sectionLines.pop();
|
||||
}
|
||||
return sectionLines.join("\n");
|
||||
}
|
||||
|
||||
function git(args, opts = {}) {
|
||||
// maxBuffer: the default 1 MiB overflows on `git show origin/main:CHANGELOG.md`
|
||||
// (the CHANGELOG alone is >1 MiB) — ENOBUFS found live in the v3.8.45 run (2026-07-06).
|
||||
return execFileSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, ...opts }).trim();
|
||||
}
|
||||
|
||||
function main() {
|
||||
const NEXT = process.argv[2];
|
||||
if (!/^\d+\.\d+\.\d+$/.test(NEXT || "")) {
|
||||
console.error("usage: node scripts/release/sync-next-cycle.mjs <nextVersion> (e.g. 3.8.45)");
|
||||
process.exit(2);
|
||||
}
|
||||
const ROOT = git(["rev-parse", "--show-toplevel"]);
|
||||
const BRANCH = `release/v${NEXT}`;
|
||||
const WT = path.join(ROOT, ".claude", "worktrees", `sync-next-${NEXT}`);
|
||||
|
||||
git(["fetch", "origin", "main", BRANCH], { cwd: ROOT, stdio: ["ignore", "pipe", "pipe"] });
|
||||
const prevVersion = JSON.parse(git(["show", "origin/main:package.json"], { cwd: ROOT })).version;
|
||||
console.log(`[sync-next-cycle] main is v${prevVersion} → syncing into ${BRANCH}`);
|
||||
|
||||
// Worktree (idempotent — reuse if a previous run left it for conflict resolution).
|
||||
if (!fs.existsSync(WT)) {
|
||||
git(["worktree", "add", WT, BRANCH], { cwd: ROOT, stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
const mergeHeadPath = git(["rev-parse", "--path-format=absolute", "--git-path", "MERGE_HEAD"], {
|
||||
cwd: WT,
|
||||
});
|
||||
const mergeInProgress = fs.existsSync(mergeHeadPath);
|
||||
if (!mergeInProgress) {
|
||||
git(["pull", "--ff-only", "origin", BRANCH], { cwd: WT, stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
// Capture the cycle's own section BEFORE the merge touches the file.
|
||||
const cycleChangelog = fs.readFileSync(path.join(WT, "CHANGELOG.md"), "utf8");
|
||||
const nextSection = extractSection(cycleChangelog, NEXT);
|
||||
|
||||
if (!mergeInProgress) {
|
||||
try {
|
||||
execFileSync("git", ["merge", "--no-commit", "--no-ff", "origin/main"], {
|
||||
cwd: WT,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch {
|
||||
// Conflicts are expected (CHANGELOG at minimum) — handled below.
|
||||
}
|
||||
}
|
||||
|
||||
// Anti-CHANGELOG-eat: main's CHANGELOG verbatim + the cycle's section on top.
|
||||
const mainChangelog = git(["show", "origin/main:CHANGELOG.md"], { cwd: WT });
|
||||
const merged = insertNextSection(mainChangelog + "\n", nextSection, NEXT);
|
||||
// Assertions: main's latest section intact + next section present.
|
||||
if (!merged.includes(`## [${prevVersion}]`)) {
|
||||
console.error(`[sync-next-cycle] ABORT: main's ## [${prevVersion}] section missing after re-insertion`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!merged.includes(`## [${NEXT}]`)) {
|
||||
console.error(`[sync-next-cycle] ABORT: ## [${NEXT}] section missing after re-insertion`);
|
||||
process.exit(1);
|
||||
}
|
||||
fs.writeFileSync(path.join(WT, "CHANGELOG.md"), merged);
|
||||
git(["add", "CHANGELOG.md"], { cwd: WT });
|
||||
|
||||
// i18n mirrors: regenerate instead of merging them one by one.
|
||||
const mirrors = git(["diff", "--name-only", "--diff-filter=U"], { cwd: WT })
|
||||
.split("\n")
|
||||
.filter((f) => f.startsWith("docs/i18n/") && f.endsWith("CHANGELOG.md"));
|
||||
for (const m of mirrors) {
|
||||
execFileSync("git", ["checkout", "origin/main", "--", m], { cwd: WT });
|
||||
execFileSync("git", ["add", m], { cwd: WT });
|
||||
}
|
||||
try {
|
||||
execFileSync("npm", ["run", "release:sync-changelog-i18n", "--", NEXT, prevVersion], {
|
||||
cwd: WT,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
// Also propagate the just-FINALIZED [prevVersion] section (dated bullets +
|
||||
// Contributors) into the mirrors — syncing only [NEXT] leaves the shipped
|
||||
// section as "— TBD" in all 42 mirrors (found live in the v3.8.45 run).
|
||||
// Boundary = the version heading right below it in main's CHANGELOG.
|
||||
const belowPrev = versionAfter(mainChangelog, prevVersion);
|
||||
if (belowPrev) {
|
||||
execFileSync("npm", ["run", "release:sync-changelog-i18n", "--", prevVersion, belowPrev], {
|
||||
cwd: WT,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
execFileSync("git", ["add", "-A", "docs/i18n"], { cwd: WT });
|
||||
} catch (e) {
|
||||
console.warn("[sync-next-cycle] i18n mirror resync failed (resolve manually):", e.message);
|
||||
}
|
||||
|
||||
// Anything still conflicted is for the human (migrations, lockfile, code).
|
||||
const unresolved = git(["diff", "--name-only", "--diff-filter=U"], { cwd: WT })
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
if (unresolved.length) {
|
||||
console.error(
|
||||
`[sync-next-cycle] ${unresolved.length} conflict(s) need manual resolution in ${WT}:\n` +
|
||||
unresolved.map((f) => ` ✗ ${f}`).join("\n") +
|
||||
`\n → resolve + git add, then re-run this script (it resumes the merge).` +
|
||||
`\n → migrations: renumber per the cross-PR collision precedent; lockfile: npm install.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
git(["commit", "-m", `chore(release): sync main (v${prevVersion} close) into ${BRANCH} — parallel-cycle sync-back`], { cwd: WT });
|
||||
git(["push", "origin", BRANCH], { cwd: WT });
|
||||
|
||||
const left = git(["rev-list", "--count", `${BRANCH}..origin/main`], { cwd: WT });
|
||||
console.log(`[sync-next-cycle] pushed. origin/main commits not in ${BRANCH}: ${left} (expected 0)`);
|
||||
|
||||
git(["worktree", "remove", "--force", WT], { cwd: ROOT });
|
||||
console.log("[sync-next-cycle] done — worktree removed.");
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
||||
Reference in New Issue
Block a user