Files
esengine 11920eb387 ci(release-studio): credit pull request authors and issue fixers in published notes
The publish job now renders release-notes/studio/<semver>.md before it
signs anything. A #N that is a pull request gains "by @author"; a #N that
is an issue gains "fixed in #M by @author" for each merged pull request
GitHub links as closing it (the ClosedEvent closer and
closedByPullRequestsReferences, read over GraphQL; mentions are not
fixes). An issue fixed by a direct push stays plain. A 贡献者 list of the
credited humans, in first-appearance order, closes the notes. Bot authors
are never credited; the source file is not rewritten.

scripts/markdown-refs.mjs decides what is a reference: #N at a line start
or after whitespace, "(", "(" or a list separator, outside code spans,
backtick and tilde fences (each closed only by its own kind), indented
code, HTML comments and link targets. A number that names a discussion or
nothing stays plain with a warning instead of failing the release.

Lookups retry with backoff on 5xx, rate limits and dropped connections. A
failure that persists, or an authorisation failure, fails the job with a
release_credits.* code before signing. The publish job gains
issues: read and pull-requests: read.

CI gains a release-notes-scripts job, gated on scripts/ and
release-notes/studio/, that runs the three test files with contents: read.

scripts/release-credits.mjs is the same module the 1.x renderer uses.
2026-09-26 23:28:25 -07:00

90 lines
3.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Finds the #N references in Markdown prose. A number counts only where a
// reference is written: at the start of a line, or after whitespace, "(", "("
// or a list separator ("、", ",", ","). Fenced and indented code, code spans,
// HTML comments, link targets ("](#N") and escaped "\#N" are never references.
const refPattern = /(?<=^|[\s(、,,]|(?<!\])\()#(\d+)(?![\w-])/g;
const codeSpanPattern = /(`+)[\s\S]*?\1/g;
const listItemPattern = /^ {0,3}(?:[-*+]|\d+[.)])(?:\s|$)/;
const masked = "\u0001";
function mask(text, start, end) {
return text.slice(0, start) + masked.repeat(end - start) + text.slice(end);
}
// Returns, per line, the text with every non-prose span replaced by a
// same-length run of a non-space placeholder, so match offsets still index the
// original line and a placeholder never reads as the whitespace a ref needs.
function proseLines(markdown) {
const lines = markdown.split("\n");
let fence = null;
let comment = false;
let previousBlank = true;
let previousCode = false;
let inList = false;
return lines.map((line) => {
const blank = line.trim() === "";
const opening = /^ {0,3}(`{3,}|~{3,})/.exec(line);
if (fence) {
const closing = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line);
if (closing && closing[1][0] === fence[0] && closing[1].length >= fence.length) fence = null;
previousBlank = false;
return masked.repeat(line.length);
}
if (opening && !comment) {
fence = opening[1];
previousBlank = false;
return masked.repeat(line.length);
}
const indented = /^( {4}|\t)/.test(line);
if (!comment && indented && !inList && (previousBlank || previousCode)) {
previousCode = true;
previousBlank = false;
return masked.repeat(line.length);
}
previousCode = false;
if (listItemPattern.test(line)) inList = true;
else if (!blank && !indented) inList = false;
previousBlank = blank;
let text = line;
for (const match of text.matchAll(codeSpanPattern)) {
if (!comment) text = mask(text, match.index, match.index + match[0].length);
}
let from = 0;
while (from <= text.length) {
if (comment) {
const end = text.indexOf("-->", from);
if (end === -1) return mask(text, from, text.length);
text = mask(text, from, end + 3);
from = end + 3;
comment = false;
}
const start = text.indexOf("<!--", from);
if (start === -1) break;
comment = true;
from = start;
}
return text;
});
}
export function markdownRefs(markdown) {
return proseLines(markdown).flatMap((prose) => [...prose.matchAll(refPattern)].map((match) => Number(match[1])));
}
// Appends suffixFor(N) after each reference; everything else is left byte for byte.
export function annotateMarkdown(markdown, suffixFor) {
const source = markdown.split("\n");
return proseLines(markdown)
.map((prose, index) => {
let line = source[index];
for (const match of [...prose.matchAll(refPattern)].reverse()) {
const end = match.index + match[0].length;
line = line.slice(0, end) + suffixFor(Number(match[1])) + line.slice(end);
}
return line;
})
.join("\n");
}