@opengsd/gsd-core 1.20.1 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +33 -2
- package/agents/gsd-executor.md +1 -1
- package/agents/gsd-plan-checker.md +1 -1
- package/agents/gsd-planner.md +3 -3
- package/agents/gsd-verifier.md +2 -2
- package/bin/install.js +234 -126
- package/commands/gsd/autonomous.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +450 -753
- package/gsd-core/bin/lib/capability-activation.cjs +8 -2
- package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
- package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
- package/gsd-core/bin/lib/capability-lock.cjs +19 -27
- package/gsd-core/bin/lib/capability-registry.cjs +74 -71
- package/gsd-core/bin/lib/capability-validator.cjs +1 -0
- package/gsd-core/bin/lib/capability-writer.cjs +7 -4
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
- package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
- package/gsd-core/bin/lib/cli-surface.cjs +545 -0
- package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
- package/gsd-core/bin/lib/commands.cjs +80 -2
- package/gsd-core/bin/lib/config-file-read.cjs +85 -0
- package/gsd-core/bin/lib/config-loader.cjs +23 -15
- package/gsd-core/bin/lib/config.cjs +139 -101
- package/gsd-core/bin/lib/configuration.cjs +41 -2
- package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
- package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
- package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
- package/gsd-core/bin/lib/gap-checker.cjs +12 -1
- package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
- package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
- package/gsd-core/bin/lib/graphify.cjs +19 -2
- package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
- package/gsd-core/bin/lib/init.cjs +13 -11
- package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
- package/gsd-core/bin/lib/install-engine.cjs +11 -19
- package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
- package/gsd-core/bin/lib/markdown-table.cjs +39 -1
- package/gsd-core/bin/lib/milestone.cjs +8 -6
- package/gsd-core/bin/lib/model-resolver.cjs +8 -4
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
- package/gsd-core/bin/lib/phase-locator.cjs +8 -3
- package/gsd-core/bin/lib/phase.cjs +107 -31
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
- package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
- package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
- package/gsd-core/bin/lib/profile-output.cjs +9 -6
- package/gsd-core/bin/lib/project-context.cjs +439 -0
- package/gsd-core/bin/lib/project-root.cjs +9 -230
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
- package/gsd-core/bin/lib/report-parser.cjs +16 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
- package/gsd-core/bin/lib/roadmap.cjs +2 -2
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
- package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
- package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
- package/gsd-core/bin/lib/state-transition.cjs +91 -41
- package/gsd-core/bin/lib/state.cjs +446 -61
- package/gsd-core/bin/lib/surface.cjs +4 -7
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
- package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
- package/gsd-core/bin/lib/verify.cjs +49 -6
- package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
- package/gsd-core/references/autonomous-smart-discuss.md +99 -18
- package/gsd-core/references/checkpoints.md +1 -1
- package/gsd-core/references/planner-guidance.md +11 -0
- package/gsd-core/references/planner-human-verify-mode.md +1 -1
- package/gsd-core/references/planner-reversibility.md +2 -0
- package/gsd-core/references/planner-verify-command-grounding.md +22 -6
- package/gsd-core/workflows/autonomous.md +4 -4
- package/gsd-core/workflows/code-review-fix.md +5 -7
- package/gsd-core/workflows/execute-phase.md +21 -7
- package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
- package/gsd-core/workflows/plan-phase.md +18 -19
- package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
- package/hooks/dist/gsd-config-reload.js +2 -2
- package/hooks/dist/gsd-context-monitor.js +2 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
- package/hooks/dist/gsd-read-injection-scanner.js +2 -2
- package/hooks/dist/gsd-secret-read-guard.js +141 -6
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/lib/config-read.js +49 -0
- package/hooks/dist/lib/git-cmd.js +3 -1
- package/hooks/dist/lib/git-head-tree.js +474 -0
- package/hooks/dist/lib/isolation-sentinel.js +97 -17
- package/hooks/dist/lib/secret-read-exemptions.js +161 -0
- package/hooks/gsd-agent-isolation-guard.js +3 -2
- package/hooks/gsd-config-reload.js +2 -2
- package/hooks/gsd-context-monitor.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +8 -5
- package/hooks/gsd-read-injection-scanner.js +2 -2
- package/hooks/gsd-secret-read-guard.js +141 -6
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/lib/config-read.js +49 -0
- package/hooks/lib/git-cmd.js +3 -1
- package/hooks/lib/git-head-tree.js +474 -0
- package/hooks/lib/isolation-sentinel.js +97 -17
- package/hooks/lib/secret-read-exemptions.js +161 -0
- package/package.json +7 -2
- package/pi/package-agents/gsd-executor.md +1 -1
- package/pi/package-agents/gsd-plan-checker.md +1 -1
- package/pi/package-agents/gsd-planner.md +3 -3
- package/pi/package-agents/gsd-verifier.md +2 -2
- package/pi/package-agents/manifest.json +4 -4
- package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
- package/scripts/changeset/cli.cjs +2 -2
- package/scripts/changeset/serialize.cjs +12 -2
- package/scripts/ci-compact-v8-coverage.cjs +204 -0
- package/scripts/ci-fix-next-rerun.cjs +149 -0
- package/scripts/ci-next-health.cjs +52 -9
- package/scripts/ci-test-scope.cjs +77 -71
- package/scripts/command-contract-helpers.cjs +56 -7
- package/scripts/docs-guard-registry.cjs +3 -0
- package/scripts/gen-cli-surface-manifest.cjs +287 -0
- package/scripts/gen-platform-conformance-tier.cjs +258 -19
- package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
- package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
- package/scripts/lib/shellcheck-fetch.cjs +71 -4
- package/scripts/lint-removed-but-needed.cjs +562 -50
- package/scripts/release-notes/changelog-release-notes.cjs +345 -0
- package/scripts/release-notes/discord-release-summary.cjs +82 -1
- package/scripts/timeout-evidence-from-ci.cjs +964 -0
- package/skills/gsd-autonomous/SKILL.md +1 -1
- package/vscode/package.json +1 -1
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build a stable GitHub Release body from the release's CHANGELOG.md section
|
|
6
|
+
* (#5922).
|
|
7
|
+
*
|
|
8
|
+
* GitHub's generated notes list PRs whose merge commits lie in the tag range.
|
|
9
|
+
* A hotfix's fixes are cherry-picks with new shas, so that list is empty for
|
|
10
|
+
* them. The curated record is the `## [X.Y.Z] - DATE` section that the
|
|
11
|
+
* finalize job's "Promote CHANGELOG" step writes, so the body is built from it.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* node scripts/release-notes/changelog-release-notes.cjs --version <X.Y.Z> \
|
|
15
|
+
* --output <file> [--changelog <path>] [--package <name>] [--repo <owner/repo>] \
|
|
16
|
+
* [--previous <X.Y.Z>]
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
const { ExitError, runMain } = require('../lib/cli-exit.cjs');
|
|
24
|
+
const { parseChangelog, RELEASE_HEADING_RE, EMPTY_RELEASE_PLACEHOLDER } = require('../changeset/serialize.cjs');
|
|
25
|
+
const { isStableTripletSemver } = require('../../gsd-core/bin/lib/semver-compare.cjs');
|
|
26
|
+
const { packageName: defaultPackageName, repoSlug: defaultRepoSlug } = require('../../gsd-core/bin/lib/package-identity.cjs');
|
|
27
|
+
|
|
28
|
+
/** Typed failure reasons; every one maps to a non-zero CLI exit. */
|
|
29
|
+
const CHANGELOG_NOTES_ERROR = Object.freeze({
|
|
30
|
+
INVALID_VERSION: 'invalid-version',
|
|
31
|
+
INVALID_PREVIOUS: 'invalid-previous',
|
|
32
|
+
NO_SECTION: 'no-section',
|
|
33
|
+
UNDATED_SECTION: 'undated-section',
|
|
34
|
+
EMPTY_SECTION: 'empty-section',
|
|
35
|
+
NO_PREVIOUS: 'no-previous',
|
|
36
|
+
OUTPUT_NOT_ALLOWED: 'output-not-allowed',
|
|
37
|
+
BUDGET_TOO_SMALL: 'budget-too-small',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
class ChangelogNotesError extends Error {
|
|
41
|
+
constructor(code, message) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = 'ChangelogNotesError';
|
|
44
|
+
this.code = code;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// GitHub rejects release bodies over 125,000 characters; stay well inside.
|
|
49
|
+
const RELEASE_BODY_MAX_CHARS = 120000;
|
|
50
|
+
|
|
51
|
+
// Stricter than isStableTripletSemver (src/semver-compare.cts:49-51), which
|
|
52
|
+
// strips a leading `v` before testing: --previous must be a bare X.Y.Z, so a
|
|
53
|
+
// `v1.2.0` is rejected here rather than silently accepted.
|
|
54
|
+
const STRICT_TRIPLET_RE = /^\d+\.\d+\.\d+$/;
|
|
55
|
+
const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
56
|
+
|
|
57
|
+
function sliceBody(lines, startIdx, endIdx) {
|
|
58
|
+
const body = lines.slice(startIdx, endIdx);
|
|
59
|
+
while (body.length > 0 && body[0].trim() === '') body.shift();
|
|
60
|
+
while (body.length > 0 && body[body.length - 1].trim() === '') body.pop();
|
|
61
|
+
return body.join('\n');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Split a section body into ordered units: `###` headings, top-level bullets
|
|
66
|
+
* (with their continuation lines) and any leading text. Raw lines are kept so
|
|
67
|
+
* concatenating all units reproduces the body exactly.
|
|
68
|
+
*/
|
|
69
|
+
function splitUnits(body) {
|
|
70
|
+
const units = [];
|
|
71
|
+
for (const line of body.split('\n')) {
|
|
72
|
+
if (/^#{3,6}\s/.test(line)) units.push({ kind: 'heading', lines: [line] });
|
|
73
|
+
else if (/^[-*]\s/.test(line)) units.push({ kind: 'bullet', lines: [line] });
|
|
74
|
+
else if (units.length === 0) units.push({ kind: 'text', lines: [line] });
|
|
75
|
+
else units[units.length - 1].lines.push(line);
|
|
76
|
+
}
|
|
77
|
+
return units;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {object} opts
|
|
82
|
+
* @param {string} opts.changelog Full CHANGELOG.md text.
|
|
83
|
+
* @param {string} opts.version Version, with or without a leading "v".
|
|
84
|
+
* @param {string} [opts.packageName]
|
|
85
|
+
* @param {string} [opts.repoSlug]
|
|
86
|
+
* @param {string} [opts.previous] Explicit previous release (strict X.Y.Z).
|
|
87
|
+
* @param {number} [opts.maxChars] Body budget (default RELEASE_BODY_MAX_CHARS).
|
|
88
|
+
* @returns {string} Release body, trailing newline included.
|
|
89
|
+
* @throws {ChangelogNotesError}
|
|
90
|
+
*/
|
|
91
|
+
function buildChangelogReleaseNotes({
|
|
92
|
+
changelog,
|
|
93
|
+
version,
|
|
94
|
+
packageName = defaultPackageName,
|
|
95
|
+
repoSlug = defaultRepoSlug,
|
|
96
|
+
previous = null,
|
|
97
|
+
maxChars = RELEASE_BODY_MAX_CHARS,
|
|
98
|
+
}) {
|
|
99
|
+
const ver = String(version || '').replace(/^v/, '');
|
|
100
|
+
if (!VERSION_RE.test(ver)) {
|
|
101
|
+
throw new ChangelogNotesError(
|
|
102
|
+
CHANGELOG_NOTES_ERROR.INVALID_VERSION,
|
|
103
|
+
`invalid version "${version}" (expected X.Y.Z or X.Y.Z-prerelease)`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (previous !== null && previous !== undefined && !STRICT_TRIPLET_RE.test(String(previous))) {
|
|
107
|
+
throw new ChangelogNotesError(
|
|
108
|
+
CHANGELOG_NOTES_ERROR.INVALID_PREVIOUS,
|
|
109
|
+
`invalid --previous "${previous}" (expected a strict X.Y.Z triplet)`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const text = String(changelog || '');
|
|
114
|
+
const { releases } = parseChangelog(text);
|
|
115
|
+
const lines = text.split(/\r?\n/);
|
|
116
|
+
// parseChangelog pushes exactly one release per heading line matched by
|
|
117
|
+
// RELEASE_HEADING_RE, so heading line i belongs to releases[i].
|
|
118
|
+
const headingLines = [];
|
|
119
|
+
lines.forEach((line, idx) => {
|
|
120
|
+
if (RELEASE_HEADING_RE.test(line)) headingLines.push(idx);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const relIdx = releases.findIndex((r) => r.version === ver);
|
|
124
|
+
if (relIdx === -1) {
|
|
125
|
+
throw new ChangelogNotesError(
|
|
126
|
+
CHANGELOG_NOTES_ERROR.NO_SECTION,
|
|
127
|
+
`CHANGELOG.md has no \`## [${ver}]\` section`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const release = releases[relIdx];
|
|
131
|
+
if (!release.date) {
|
|
132
|
+
throw new ChangelogNotesError(
|
|
133
|
+
CHANGELOG_NOTES_ERROR.UNDATED_SECTION,
|
|
134
|
+
`CHANGELOG.md heading \`## [${ver}]\` has no date (expected \`## [${ver}] - YYYY-MM-DD\`)`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const bulletCount = release.sections.reduce((n, s) => n + s.bullets.length, 0);
|
|
138
|
+
const endIdx = relIdx + 1 < headingLines.length ? headingLines[relIdx + 1] : lines.length;
|
|
139
|
+
const body = sliceBody(lines, headingLines[relIdx] + 1, endIdx);
|
|
140
|
+
// `render --allow-empty` writes a dated heading plus a placeholder for a
|
|
141
|
+
// zero-fragment release; that is a valid (if sparse) section.
|
|
142
|
+
if (bulletCount === 0 && body !== EMPTY_RELEASE_PLACEHOLDER) {
|
|
143
|
+
throw new ChangelogNotesError(
|
|
144
|
+
CHANGELOG_NOTES_ERROR.EMPTY_SECTION,
|
|
145
|
+
`CHANGELOG.md section \`## [${ver}]\` has no bullets`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let prev = previous === null || previous === undefined ? null : String(previous);
|
|
150
|
+
if (prev === null) {
|
|
151
|
+
const earlier = releases
|
|
152
|
+
.slice(relIdx + 1)
|
|
153
|
+
.find((r) => r.date && isStableTripletSemver(r.version));
|
|
154
|
+
if (!earlier) {
|
|
155
|
+
throw new ChangelogNotesError(
|
|
156
|
+
CHANGELOG_NOTES_ERROR.NO_PREVIOUS,
|
|
157
|
+
`CHANGELOG.md has no dated X.Y.Z section below \`## [${ver}]\`; pass --previous <X.Y.Z>`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
prev = earlier.version;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const install = [
|
|
164
|
+
'## Install',
|
|
165
|
+
'',
|
|
166
|
+
'```bash',
|
|
167
|
+
`npm i ${packageName}@${ver}`,
|
|
168
|
+
'# or',
|
|
169
|
+
`npm i ${packageName}@latest`,
|
|
170
|
+
'```',
|
|
171
|
+
].join('\n');
|
|
172
|
+
const footer = `**Full Changelog**: https://github.com/${repoSlug}/compare/v${prev}...v${ver}`;
|
|
173
|
+
const changelogUrl = `https://github.com/${repoSlug}/blob/v${ver}/CHANGELOG.md`;
|
|
174
|
+
const assemble = (bodyText, dropped) => {
|
|
175
|
+
// An empty body (nothing fit) leaves just the heading, never a blank run.
|
|
176
|
+
const parts = [install, bodyText ? `## What's Changed\n\n${bodyText}` : "## What's Changed"];
|
|
177
|
+
if (dropped > 0) {
|
|
178
|
+
const noun = dropped === 1 ? 'entry' : 'entries';
|
|
179
|
+
parts.push(`_\u2026and ${dropped} more ${noun}. See the full [CHANGELOG](${changelogUrl})._`);
|
|
180
|
+
}
|
|
181
|
+
parts.push(footer);
|
|
182
|
+
return parts.join('\n\n') + '\n';
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const full = assemble(body, 0);
|
|
186
|
+
if (full.length <= maxChars) return full;
|
|
187
|
+
|
|
188
|
+
// Over budget: keep whole units (headings, bullets with continuations) in
|
|
189
|
+
// order until the next one would not fit alongside the notice and footer.
|
|
190
|
+
const units = splitUnits(body);
|
|
191
|
+
let remaining = units.filter((u) => u.kind === 'bullet').length;
|
|
192
|
+
// The smallest possible body carries no entries at all; if even that is over
|
|
193
|
+
// budget no truncation can help, so fail instead of returning an oversized body.
|
|
194
|
+
const floor = assemble('', remaining).length;
|
|
195
|
+
if (floor > maxChars) {
|
|
196
|
+
throw new ChangelogNotesError(
|
|
197
|
+
CHANGELOG_NOTES_ERROR.BUDGET_TOO_SMALL,
|
|
198
|
+
`release body budget ${maxChars} is below the ${floor}-character floor (install block, notice and footer)`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const kept = [];
|
|
202
|
+
for (const unit of units) {
|
|
203
|
+
const after = remaining - (unit.kind === 'bullet' ? 1 : 0);
|
|
204
|
+
const candidate = [...kept, unit].map((u) => u.lines.join('\n')).join('\n').replace(/\s+$/, '');
|
|
205
|
+
if (assemble(candidate, after).length > maxChars) break;
|
|
206
|
+
kept.push(unit);
|
|
207
|
+
remaining = after;
|
|
208
|
+
}
|
|
209
|
+
// Never leave a heading with no entries under it.
|
|
210
|
+
while (kept.length > 0 && kept[kept.length - 1].kind === 'heading') kept.pop();
|
|
211
|
+
const keptBullets = kept.filter((u) => u.kind === 'bullet').length;
|
|
212
|
+
const dropped = units.filter((u) => u.kind === 'bullet').length - keptBullets;
|
|
213
|
+
const keptBody = kept.map((u) => u.lines.join('\n')).join('\n').replace(/\s+$/, '');
|
|
214
|
+
return assemble(keptBody, dropped);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Resolve `--output` and require it to sit under the working directory, the OS
|
|
219
|
+
* temp directory or RUNNER_TEMP (symlinks resolved on the parent directory).
|
|
220
|
+
* An existing symlink as the final component is refused outright.
|
|
221
|
+
* @returns {string} Absolute path to write.
|
|
222
|
+
* @throws {ChangelogNotesError}
|
|
223
|
+
*/
|
|
224
|
+
function resolveOutputPath(output, { cwd = process.cwd(), tmpdir = os.tmpdir(), runnerTemp = process.env.RUNNER_TEMP } = {}) {
|
|
225
|
+
const abs = path.resolve(cwd, output);
|
|
226
|
+
let realParent;
|
|
227
|
+
try {
|
|
228
|
+
realParent = fs.realpathSync(path.dirname(abs));
|
|
229
|
+
} catch (_e) {
|
|
230
|
+
throw new ChangelogNotesError(CHANGELOG_NOTES_ERROR.OUTPUT_NOT_ALLOWED, `--output directory does not exist: ${path.dirname(abs)}`);
|
|
231
|
+
}
|
|
232
|
+
const target = path.join(realParent, path.basename(abs));
|
|
233
|
+
for (const root of [cwd, tmpdir, runnerTemp]) {
|
|
234
|
+
if (!root) continue;
|
|
235
|
+
let realRoot;
|
|
236
|
+
try {
|
|
237
|
+
realRoot = fs.realpathSync(root);
|
|
238
|
+
} catch (_e) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (target.startsWith(realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep)) {
|
|
242
|
+
let st = null;
|
|
243
|
+
try {
|
|
244
|
+
st = fs.lstatSync(target);
|
|
245
|
+
} catch (_e) {
|
|
246
|
+
st = null; // does not exist yet
|
|
247
|
+
}
|
|
248
|
+
if (st && st.isSymbolicLink()) {
|
|
249
|
+
throw new ChangelogNotesError(CHANGELOG_NOTES_ERROR.OUTPUT_NOT_ALLOWED, `--output ${output} is a symbolic link`);
|
|
250
|
+
}
|
|
251
|
+
return target;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
throw new ChangelogNotesError(
|
|
255
|
+
CHANGELOG_NOTES_ERROR.OUTPUT_NOT_ALLOWED,
|
|
256
|
+
`--output ${output} is outside the working directory, the temp directory and RUNNER_TEMP`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Write the body without following a symlink at the final path component.
|
|
262
|
+
* O_NOFOLLOW (where defined) closes the window between resolveOutputPath's
|
|
263
|
+
* lstat and the open; ELOOP is mapped to a typed error.
|
|
264
|
+
* @throws {ChangelogNotesError}
|
|
265
|
+
*/
|
|
266
|
+
function writeOutput(target, body) {
|
|
267
|
+
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | (fs.constants.O_NOFOLLOW || 0);
|
|
268
|
+
let fd;
|
|
269
|
+
try {
|
|
270
|
+
fd = fs.openSync(target, flags, 0o644);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
if (err && err.code === 'ELOOP') {
|
|
273
|
+
throw new ChangelogNotesError(CHANGELOG_NOTES_ERROR.OUTPUT_NOT_ALLOWED, `--output ${target} is a symbolic link`);
|
|
274
|
+
}
|
|
275
|
+
throw err;
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
fs.writeFileSync(fd, body, 'utf8');
|
|
279
|
+
} finally {
|
|
280
|
+
fs.closeSync(fd);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const FLAGS_WITH_VALUE = new Set([
|
|
285
|
+
'--version', '--tag', '--changelog', '--output', '--package', '--repo', '--previous',
|
|
286
|
+
]);
|
|
287
|
+
|
|
288
|
+
function parseArgs(argv) {
|
|
289
|
+
const opts = {};
|
|
290
|
+
for (let i = 0; i < argv.length; i++) {
|
|
291
|
+
const arg = argv[i];
|
|
292
|
+
if (!FLAGS_WITH_VALUE.has(arg)) throw new ExitError(2, `error: unknown argument ${arg}`);
|
|
293
|
+
const value = argv[++i];
|
|
294
|
+
if (value === undefined || value === '' || value.startsWith('--')) {
|
|
295
|
+
throw new ExitError(2, `error: ${arg} requires a value`);
|
|
296
|
+
}
|
|
297
|
+
opts[arg.slice(2)] = value;
|
|
298
|
+
}
|
|
299
|
+
return opts;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function main() {
|
|
303
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
304
|
+
const version = opts.version || opts.tag;
|
|
305
|
+
if (!version) throw new ExitError(2, 'error: --version <X.Y.Z> is required');
|
|
306
|
+
if (!opts.output) throw new ExitError(2, 'error: --output <file> is required');
|
|
307
|
+
|
|
308
|
+
const changelogPath = path.resolve(opts.changelog || path.join(__dirname, '..', '..', 'CHANGELOG.md'));
|
|
309
|
+
if (!fs.existsSync(changelogPath)) {
|
|
310
|
+
throw new ExitError(1, `error: CHANGELOG not found: ${changelogPath}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
let body;
|
|
314
|
+
let outputPath;
|
|
315
|
+
try {
|
|
316
|
+
outputPath = resolveOutputPath(opts.output);
|
|
317
|
+
body = buildChangelogReleaseNotes({
|
|
318
|
+
changelog: fs.readFileSync(changelogPath, 'utf8'),
|
|
319
|
+
version,
|
|
320
|
+
packageName: opts.package || defaultPackageName,
|
|
321
|
+
repoSlug: opts.repo || defaultRepoSlug,
|
|
322
|
+
previous: opts.previous === undefined ? null : opts.previous,
|
|
323
|
+
});
|
|
324
|
+
writeOutput(outputPath, body);
|
|
325
|
+
} catch (err) {
|
|
326
|
+
if (err instanceof ChangelogNotesError) {
|
|
327
|
+
throw new ExitError(1, `error[${err.code}]: ${err.message}`);
|
|
328
|
+
}
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (require.main === module) {
|
|
334
|
+
runMain(main);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
module.exports = {
|
|
338
|
+
buildChangelogReleaseNotes,
|
|
339
|
+
ChangelogNotesError,
|
|
340
|
+
CHANGELOG_NOTES_ERROR,
|
|
341
|
+
RELEASE_BODY_MAX_CHARS,
|
|
342
|
+
resolveOutputPath,
|
|
343
|
+
writeOutput,
|
|
344
|
+
parseArgs,
|
|
345
|
+
};
|
|
@@ -11,6 +11,8 @@ const DEFAULT_MAX_CONTENT = 1850;
|
|
|
11
11
|
const SECTION_LIMITS = Object.freeze([
|
|
12
12
|
['Breaking', 4],
|
|
13
13
|
['Security', 4],
|
|
14
|
+
['Removed', 3],
|
|
15
|
+
['Deprecated', 3],
|
|
14
16
|
['Feature', 4],
|
|
15
17
|
['Fix', 4],
|
|
16
18
|
['Enhancement', 3],
|
|
@@ -49,6 +51,14 @@ function normalizeHeading(value) {
|
|
|
49
51
|
|
|
50
52
|
if (lower.includes('breaking')) return 'Breaking';
|
|
51
53
|
if (lower.includes('security')) return 'Security';
|
|
54
|
+
// Keep-a-Changelog headings (#5922): the release body is built from the
|
|
55
|
+
// CHANGELOG section, whose subsections use these names.
|
|
56
|
+
if (lower === 'added') return 'Feature';
|
|
57
|
+
if (lower === 'fixed') return 'Fix';
|
|
58
|
+
if (lower === 'changed') return 'Enhancement';
|
|
59
|
+
if (lower === 'deprecated') return 'Deprecated';
|
|
60
|
+
// Note: an old-format body with a `## Removed` heading now buckets under Removed (#5922).
|
|
61
|
+
if (lower === 'removed') return 'Removed';
|
|
52
62
|
if (lower === 'feature' || lower === 'features') return 'Feature';
|
|
53
63
|
if (lower === 'fix' || lower === 'fixes' || lower === 'bug fixes') return 'Fix';
|
|
54
64
|
if (lower === 'enhancement' || lower === 'enhancements' || lower === 'improvements') return 'Enhancement';
|
|
@@ -58,11 +68,21 @@ function normalizeHeading(value) {
|
|
|
58
68
|
return heading;
|
|
59
69
|
}
|
|
60
70
|
|
|
71
|
+
// A body built from a CHANGELOG section (#5922) carries Keep-a-Changelog
|
|
72
|
+
// subsection headings. GitHub-generated and formatter output never does, so
|
|
73
|
+
// its bullets keep the original (unchanged) handling.
|
|
74
|
+
const CHANGELOG_HEADING_RE = /^###\s+(?:Added|Changed|Deprecated|Removed|Fixed|Security)\s*$/m;
|
|
75
|
+
|
|
61
76
|
function collectSections(body) {
|
|
77
|
+
const text = normalizeNewlines(body);
|
|
78
|
+
return CHANGELOG_HEADING_RE.test(text) ? collectChangelogSections(text) : collectLegacySections(text);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function collectLegacySections(text) {
|
|
62
82
|
const sections = new Map();
|
|
63
83
|
let current = null;
|
|
64
84
|
|
|
65
|
-
for (const line of
|
|
85
|
+
for (const line of text.split('\n')) {
|
|
66
86
|
const trimmed = line.trim();
|
|
67
87
|
const heading = trimmed.match(/^#{2,3}\s+(.+)$/);
|
|
68
88
|
|
|
@@ -86,6 +106,67 @@ function collectSections(body) {
|
|
|
86
106
|
return sections;
|
|
87
107
|
}
|
|
88
108
|
|
|
109
|
+
function collectChangelogSections(text) {
|
|
110
|
+
const sections = new Map();
|
|
111
|
+
let current = null;
|
|
112
|
+
// Bullet in flight: CHANGELOG bullets wrap onto indented continuation lines
|
|
113
|
+
// that carry the trailing (#issue) (#pr) references.
|
|
114
|
+
let pending = null;
|
|
115
|
+
|
|
116
|
+
function flush() {
|
|
117
|
+
if (!pending) return;
|
|
118
|
+
if (!sections.has(pending.section)) sections.set(pending.section, []);
|
|
119
|
+
sections.get(pending.section).push(summarizeChangelogBullet(pending.text) || cleanBullet(pending.text));
|
|
120
|
+
pending = null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const line of text.split('\n')) {
|
|
124
|
+
const trimmed = line.trim();
|
|
125
|
+
const heading = trimmed.match(/^#{2,3}\s+(.+)$/);
|
|
126
|
+
|
|
127
|
+
if (heading) {
|
|
128
|
+
flush();
|
|
129
|
+
current = normalizeHeading(heading[1]);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Indented bullets are nested detail of a CHANGELOG bullet, not entries.
|
|
134
|
+
if (/^\s+[*-]\s/.test(line)) {
|
|
135
|
+
flush();
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const bullet = trimmed.match(/^([*-])\s+(.+)$/);
|
|
140
|
+
if (!bullet || !current) {
|
|
141
|
+
if (pending && /^\s+\S/.test(line)) pending.text += ` ${trimmed}`;
|
|
142
|
+
else flush();
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
flush();
|
|
147
|
+
const section = current === "What's Changed" ? classifyTitle(trimmed) : current;
|
|
148
|
+
pending = { section, text: trimmed };
|
|
149
|
+
}
|
|
150
|
+
flush();
|
|
151
|
+
|
|
152
|
+
return sections;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* CHANGELOG bullets read `- **Headline** — long explanation (#issue) (#pr)`.
|
|
157
|
+
* The bold headline is the user-visible summary; keep it and the trailing
|
|
158
|
+
* issue/PR references. Returns null for any other bullet shape.
|
|
159
|
+
* @param {string} bulletLine
|
|
160
|
+
* @returns {string|null}
|
|
161
|
+
*/
|
|
162
|
+
function summarizeChangelogBullet(bulletLine) {
|
|
163
|
+
const m = bulletLine.match(/^[*-]\s+\*\*(.+?)\*\*\s+[—–-]\s/);
|
|
164
|
+
if (!m) return null;
|
|
165
|
+
const refs = bulletLine.match(/(?:\s\(#\d+\))+\s*$/);
|
|
166
|
+
const headline = cleanBullet(`- ${m[1]}`);
|
|
167
|
+
return refs ? `${headline}${refs[0].replace(/\s+/g, ' ').replace(/\s+$/, '')}` : headline;
|
|
168
|
+
}
|
|
169
|
+
|
|
89
170
|
function cleanBullet(value) {
|
|
90
171
|
let text = String(value)
|
|
91
172
|
.replace(/^[*-]\s+/, '')
|