-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathcap-release-notes.mjs
More file actions
132 lines (116 loc) · 3.53 KB
/
Copy pathcap-release-notes.mjs
File metadata and controls
132 lines (116 loc) · 3.53 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
#!/usr/bin/env node
// Prepares the body handed to `gh release create`. GitHub rejects release
// bodies over 125000 characters, and that rejection lands *after* the npm
// packages have been published, so an oversized or empty changelog has to
// degrade the notes rather than the release.
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// GitHub's limit is 125000; the margin absorbs the finalize step's rewrite and
// any counting difference between code points and UTF-16 units.
export const MAX_BODY_CHARS = 120000;
const TRUNCATION_NOTE = '_Release notes were truncated._';
/**
* @returns {{body: string, truncated: boolean, fallback: boolean}} a body that
* is non-empty and at most `maxChars` code points long.
*/
export function prepareReleaseNotes({
body = '',
tag,
previousTag = '',
repo = '',
serverUrl = 'https://github.com',
maxChars = MAX_BODY_CHARS,
}) {
if (!tag) {
throw new Error('prepareReleaseNotes requires a tag');
}
const trimmed = body.trim();
if (!trimmed) {
return { body: `Release ${tag}`, truncated: false, fallback: true };
}
// Split on code points so a cut never lands inside a surrogate pair and
// leaves the body invalid.
const chars = Array.from(trimmed);
if (chars.length <= maxChars) {
return { body: trimmed, truncated: false, fallback: false };
}
const compare =
previousTag && repo
? ` Full changelog: ${serverUrl}/${repo}/compare/${previousTag}...${tag}`
: '';
const footer = `\n\n${TRUNCATION_NOTE}${compare}`;
const keep = maxChars - Array.from(footer).length;
if (keep <= 0) {
return { body: `Release ${tag}`, truncated: true, fallback: true };
}
return {
body: `${chars.slice(0, keep).join('')}${footer}`,
truncated: true,
fallback: false,
};
}
function parseArgs(argv) {
const args = {
file: '',
tag: '',
previousTag: '',
repo: '',
serverUrl: 'https://github.com',
maxChars: MAX_BODY_CHARS,
};
const options = {
'--file': 'file',
'--tag': 'tag',
'--previous-tag': 'previousTag',
'--repo': 'repo',
'--server-url': 'serverUrl',
'--max-chars': 'maxChars',
};
for (let index = 0; index < argv.length; index += 1) {
const key = options[argv[index]];
if (!key) {
throw new Error(`Unknown option: ${argv[index]}`);
}
const value = argv[index + 1];
if (value === undefined) {
throw new Error(`Missing value for ${argv[index]}`);
}
args[key] = key === 'maxChars' ? Number(value) : value;
index += 1;
}
if (!args.file || !args.tag) {
throw new Error('--file and --tag are required');
}
if (!Number.isInteger(args.maxChars) || args.maxChars <= 0) {
throw new Error(`--max-chars must be a positive integer`);
}
return args;
}
function main(argv) {
const args = parseArgs(argv);
let body = '';
try {
body = readFileSync(args.file, 'utf8');
} catch {
// A failed generate-notes call leaves no file; the fallback body covers it.
}
const result = prepareReleaseNotes({ ...args, body });
writeFileSync(args.file, `${result.body}\n`);
if (result.truncated) {
process.stdout.write(
`::warning::Release notes exceeded ${args.maxChars} characters; truncated\n`,
);
}
if (result.fallback) {
process.stdout.write(
`::warning::No release notes were generated; using a minimal body\n`,
);
}
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
main(process.argv.slice(2));
}