@opengsd/gsd-core 1.20.0 → 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 +265 -161
- package/commands/gsd/autonomous.md +1 -1
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/new-project.md +2 -2
- package/gsd-core/bin/gsd-tools.cjs +554 -823
- 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 +99 -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/gsd-tool-dir.cjs +134 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
- package/gsd-core/bin/lib/init.cjs +20 -18
- package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
- package/gsd-core/bin/lib/install-engine.cjs +20 -28
- package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
- package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
- package/gsd-core/bin/lib/install-profiles.cjs +4 -2
- 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-id.cjs +26 -9
- package/gsd-core/bin/lib/phase-locator.cjs +8 -3
- package/gsd-core/bin/lib/phase.cjs +170 -45
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
- package/gsd-core/bin/lib/planning-snapshot.cjs +10 -6
- 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 +357 -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 +317 -134
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
- 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 +103 -46
- package/gsd-core/bin/lib/state.cjs +448 -63
- 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/workstream-inventory.cjs +4 -4
- package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
- 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 +4 -3
- 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 +9 -6
- 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 +168 -13
- package/hooks/dist/lib/secret-read-exemptions.js +161 -0
- package/hooks/gsd-agent-isolation-guard.js +4 -3
- package/hooks/gsd-config-reload.js +2 -2
- package/hooks/gsd-context-monitor.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +9 -6
- 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 +168 -13
- 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/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
- package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
- package/scripts/changeset/cli.cjs +2 -2
- package/scripts/changeset/lint.cjs +28 -3
- 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 +59 -47
- package/scripts/lib/platform-conformance-tier.generated.cjs +23 -64
- package/scripts/lib/shellcheck-fetch.cjs +71 -4
- package/scripts/lint-removed-but-needed.cjs +562 -50
- package/scripts/merge-commit-pr.cjs +186 -0
- package/scripts/release-ancestry.cjs +86 -0
- package/scripts/release-notes/changelog-release-notes.cjs +345 -0
- package/scripts/release-notes/discord-release-summary.cjs +82 -1
- package/scripts/release-pipeline-pr.cjs +92 -0
- package/scripts/require-issue-link-policy.cjs +18 -9
- package/scripts/sync-next-version.cjs +11 -2
- package/scripts/timeout-evidence-from-ci.cjs +964 -0
- package/skills/gsd-autonomous/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-new-project/SKILL.md +1 -1
- package/vscode/package.json +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Land one of the release pipeline's history-carrying PRs as a merge commit (#5894).
|
|
5
|
+
*
|
|
6
|
+
* Two PRs must keep a parent edge, or main and next stop sharing history and
|
|
7
|
+
* the next release -> main merge conflicts in every version field:
|
|
8
|
+
*
|
|
9
|
+
* release-merge release/X.Y.0 | hotfix/X.Y.Z -> main keeps the v<version> tag in main
|
|
10
|
+
* main-to-next-backmerge chore/backmerge-main-to-next-<sha> -> next keeps main an ancestor of next
|
|
11
|
+
*
|
|
12
|
+
* v1.19.0 broke both (#5729 and #5730 were squash-merged). This script is the
|
|
13
|
+
* one sanctioned way to merge them, from auto-backmerge.yml, release.yml and a
|
|
14
|
+
* maintainer's shell alike:
|
|
15
|
+
*
|
|
16
|
+
* 1. it merges through `PUT /repos/{repo}/pulls/{n}/merge` with
|
|
17
|
+
* merge_method=merge: never the merge queue, which squashes, and not
|
|
18
|
+
* `gh pr merge --admin`, which does not honor a ruleset bypass;
|
|
19
|
+
* 2. the merge is pinned to the head sha it read (`sha=`), so a push after
|
|
20
|
+
* the read is refused rather than merged unseen;
|
|
21
|
+
* 3. afterwards it proves the result: the merge commit has two parents, one
|
|
22
|
+
* of them the PR head, and the base branch now contains the PR head.
|
|
23
|
+
*
|
|
24
|
+
* Usage: node scripts/merge-commit-pr.cjs <pr-number> [--repo owner/name]
|
|
25
|
+
* Prints one JSON result line; exits 0 when merged and verified, else 1.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const { execFileSync } = require('node:child_process');
|
|
29
|
+
const { RELEASE_PIPELINE_PR_KIND, releasePipelinePrKind } = require('./release-pipeline-pr.cjs');
|
|
30
|
+
const { runMain } = require('./lib/cli-exit.cjs');
|
|
31
|
+
|
|
32
|
+
const MERGE_COMMIT_PR_REASON = Object.freeze({
|
|
33
|
+
MERGED: 'merged',
|
|
34
|
+
USAGE: 'usage',
|
|
35
|
+
PR_UNREADABLE: 'pr-unreadable',
|
|
36
|
+
NOT_OPEN: 'not-open',
|
|
37
|
+
NOT_A_HISTORY_PR: 'not-a-history-pr',
|
|
38
|
+
MERGE_REFUSED: 'merge-refused',
|
|
39
|
+
NOT_A_MERGE_COMMIT: 'not-a-merge-commit',
|
|
40
|
+
ANCESTRY_BROKEN: 'ancestry-broken',
|
|
41
|
+
TAG_NOT_IN_BASE: 'tag-not-in-base',
|
|
42
|
+
VERIFY_UNREADABLE: 'verify-unreadable',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const HISTORY_KINDS = Object.freeze([
|
|
46
|
+
RELEASE_PIPELINE_PR_KIND.RELEASE_MERGE,
|
|
47
|
+
RELEASE_PIPELINE_PR_KIND.MAIN_TO_NEXT_BACKMERGE,
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const PR_NUMBER_RE = /^[1-9][0-9]*$/;
|
|
51
|
+
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
52
|
+
const GH_TIMEOUT_MS = 30000;
|
|
53
|
+
const GH_MERGE_TIMEOUT_MS = 60000;
|
|
54
|
+
|
|
55
|
+
/** The release tag a release/hotfix head branch publishes: release/1.20.0 -> v1.20.0. */
|
|
56
|
+
function releaseTagFor(headRef) {
|
|
57
|
+
const match = /^(?:release|hotfix)\/((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))$/.exec(String(headRef || ''));
|
|
58
|
+
return match ? `v${match[1]}` : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Parse argv. Returns { prNumber, repo } or { error }. */
|
|
62
|
+
function parseArgs(argv, env = process.env) {
|
|
63
|
+
let prNumber = null;
|
|
64
|
+
let repo = env.GITHUB_REPOSITORY || 'open-gsd/gsd-core';
|
|
65
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
66
|
+
const arg = argv[i];
|
|
67
|
+
if (arg === '--repo') {
|
|
68
|
+
const value = argv[i + 1];
|
|
69
|
+
if (typeof value !== 'string' || !REPO_RE.test(value)) return { error: '--repo needs owner/name' };
|
|
70
|
+
repo = value;
|
|
71
|
+
i += 1;
|
|
72
|
+
} else if (prNumber === null && PR_NUMBER_RE.test(arg)) {
|
|
73
|
+
prNumber = Number(arg);
|
|
74
|
+
} else {
|
|
75
|
+
return { error: `unexpected argument: ${arg}` };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (prNumber === null) return { error: 'usage: merge-commit-pr.cjs <pr-number> [--repo owner/name]' };
|
|
79
|
+
if (!REPO_RE.test(repo)) return { error: `invalid repository: ${repo}` };
|
|
80
|
+
return { prNumber, repo };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Default gh runner: returns stdout; throws on non-zero exit (err.stderr carries gh's text). */
|
|
84
|
+
function defaultGh(args, { timeout = GH_TIMEOUT_MS } = {}) {
|
|
85
|
+
return execFileSync('gh', args, { encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function errorText(err) {
|
|
89
|
+
return String((err && (err.stderr || err.message)) || err).trim();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Merge PR `prNumber` of `repo` with a merge commit and verify the history.
|
|
94
|
+
* `gh(args, opts)` returns gh's stdout (JSON for `gh api`) and throws on failure.
|
|
95
|
+
*
|
|
96
|
+
* @returns {{ ok: boolean, reason: string, kind?: string, mergeSha?: string, detail?: string }}
|
|
97
|
+
*/
|
|
98
|
+
function mergeWithMergeCommit({ prNumber, repo, gh = defaultGh }) {
|
|
99
|
+
let pr;
|
|
100
|
+
try {
|
|
101
|
+
pr = JSON.parse(gh(['api', `repos/${repo}/pulls/${prNumber}`]));
|
|
102
|
+
} catch (err) {
|
|
103
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.PR_UNREADABLE, detail: errorText(err) };
|
|
104
|
+
}
|
|
105
|
+
if (!pr || pr.state !== 'open' || pr.merged === true) {
|
|
106
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.NOT_OPEN };
|
|
107
|
+
}
|
|
108
|
+
const head = pr.head || {};
|
|
109
|
+
const base = pr.base || {};
|
|
110
|
+
const headRepo = head.repo && typeof head.repo.full_name === 'string' ? head.repo.full_name : null;
|
|
111
|
+
const kind = releasePipelinePrKind({
|
|
112
|
+
headRef: head.ref,
|
|
113
|
+
baseRef: base.ref,
|
|
114
|
+
sameRepo: headRepo !== null && headRepo.toLowerCase() === repo.toLowerCase(),
|
|
115
|
+
});
|
|
116
|
+
if (!HISTORY_KINDS.includes(kind)) {
|
|
117
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.NOT_A_HISTORY_PR, kind: kind || undefined };
|
|
118
|
+
}
|
|
119
|
+
const headSha = head.sha;
|
|
120
|
+
|
|
121
|
+
let merge;
|
|
122
|
+
try {
|
|
123
|
+
merge = JSON.parse(gh(
|
|
124
|
+
['api', '-X', 'PUT', `repos/${repo}/pulls/${prNumber}/merge`, '-f', 'merge_method=merge', '-f', `sha=${headSha}`],
|
|
125
|
+
{ timeout: GH_MERGE_TIMEOUT_MS },
|
|
126
|
+
));
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.MERGE_REFUSED, kind, detail: errorText(err) };
|
|
129
|
+
}
|
|
130
|
+
if (!merge || merge.merged !== true || typeof merge.sha !== 'string') {
|
|
131
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.MERGE_REFUSED, kind, detail: JSON.stringify(merge) };
|
|
132
|
+
}
|
|
133
|
+
const mergeSha = merge.sha;
|
|
134
|
+
|
|
135
|
+
let commit;
|
|
136
|
+
let compare;
|
|
137
|
+
try {
|
|
138
|
+
commit = JSON.parse(gh(['api', `repos/${repo}/commits/${mergeSha}`]));
|
|
139
|
+
compare = JSON.parse(gh(['api', `repos/${repo}/compare/${headSha}...${base.ref}`]));
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.VERIFY_UNREADABLE, kind, mergeSha, detail: errorText(err) };
|
|
142
|
+
}
|
|
143
|
+
const parents = commit && Array.isArray(commit.parents) ? commit.parents.map((p) => p && p.sha) : [];
|
|
144
|
+
if (parents.length !== 2 || !parents.includes(headSha)) {
|
|
145
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.NOT_A_MERGE_COMMIT, kind, mergeSha, detail: `parents=${parents.join(',')}` };
|
|
146
|
+
}
|
|
147
|
+
if (!compare || compare.behind_by !== 0) {
|
|
148
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.ANCESTRY_BROKEN, kind, mergeSha, detail: `behind_by=${compare && compare.behind_by}` };
|
|
149
|
+
}
|
|
150
|
+
if (kind === RELEASE_PIPELINE_PR_KIND.RELEASE_MERGE) {
|
|
151
|
+
const tag = releaseTagFor(head.ref);
|
|
152
|
+
if (tag === null) {
|
|
153
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.VERIFY_UNREADABLE, kind, mergeSha, detail: 'no release tag for ' + head.ref };
|
|
154
|
+
}
|
|
155
|
+
let tagCompare;
|
|
156
|
+
try {
|
|
157
|
+
tagCompare = JSON.parse(gh(['api', `repos/${repo}/compare/${tag}...${base.ref}`]));
|
|
158
|
+
} catch (err) {
|
|
159
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.VERIFY_UNREADABLE, kind, mergeSha, detail: errorText(err) };
|
|
160
|
+
}
|
|
161
|
+
if (!tagCompare || tagCompare.behind_by !== 0) {
|
|
162
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.TAG_NOT_IN_BASE, kind, mergeSha, detail: `${tag} behind_by=${tagCompare && tagCompare.behind_by}` };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { ok: true, reason: MERGE_COMMIT_PR_REASON.MERGED, kind, mergeSha };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function main(argv = process.argv.slice(2), { gh = defaultGh, env = process.env, stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
169
|
+
const args = parseArgs(argv, env);
|
|
170
|
+
if (args.error) {
|
|
171
|
+
stderr.write(`::error::${args.error}\n`);
|
|
172
|
+
stdout.write(`${JSON.stringify({ ok: false, reason: MERGE_COMMIT_PR_REASON.USAGE })}\n`);
|
|
173
|
+
return 1;
|
|
174
|
+
}
|
|
175
|
+
const result = mergeWithMergeCommit({ prNumber: args.prNumber, repo: args.repo, gh });
|
|
176
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
177
|
+
if (!result.ok) {
|
|
178
|
+
stderr.write(`::error::PR #${args.prNumber} was not landed as a verified merge commit (${result.reason})${result.detail ? `: ${result.detail}` : ''}. `
|
|
179
|
+
+ 'Never put a release or back-merge PR in the merge queue or squash it: main and next must keep sharing history (#5894).\n');
|
|
180
|
+
}
|
|
181
|
+
return result.ok ? 0 : 1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (require.main === module) runMain(main);
|
|
185
|
+
|
|
186
|
+
module.exports = { MERGE_COMMIT_PR_REASON, HISTORY_KINDS, GH_TIMEOUT_MS, GH_MERGE_TIMEOUT_MS, releaseTagFor, parseArgs, mergeWithMergeCommit, main };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Release ancestry check (#5894): `main` must be an ancestor of `next`.
|
|
5
|
+
*
|
|
6
|
+
* auto-backmerge.yml keeps that true by landing a merge commit of main into
|
|
7
|
+
* next after every release. When the back-merge is squashed (#5730), main and
|
|
8
|
+
* next stop sharing history and the next release -> main merge conflicts in
|
|
9
|
+
* every version field. release.yml runs this before cutting or finalizing a
|
|
10
|
+
* release so the break surfaces there, with the fix, instead of as a 91-file
|
|
11
|
+
* conflict on the merge-back PR.
|
|
12
|
+
*
|
|
13
|
+
* Usage: node scripts/release-ancestry.cjs [--ancestor origin/main] [--descendant origin/next]
|
|
14
|
+
* Exits 0 when the ancestor is reachable from the descendant, else 1.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { execFileSync } = require('node:child_process');
|
|
18
|
+
const { runMain } = require('./lib/cli-exit.cjs');
|
|
19
|
+
|
|
20
|
+
const ANCESTRY_VERDICT = Object.freeze({
|
|
21
|
+
ANCESTOR: 'ancestor',
|
|
22
|
+
DIVERGED: 'diverged',
|
|
23
|
+
UNKNOWN: 'unknown',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const GIT_TIMEOUT_MS = 15000;
|
|
27
|
+
const REF_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
28
|
+
|
|
29
|
+
/** Default git runner: returns { status } for `git <args>`. */
|
|
30
|
+
function defaultGit(args) {
|
|
31
|
+
try {
|
|
32
|
+
execFileSync('git', args, { stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
|
|
33
|
+
return { status: 0 };
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return { status: typeof err.status === 'number' ? err.status : null };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `git merge-base --is-ancestor` exits 0 (ancestor), 1 (not), or another code
|
|
41
|
+
* (an error, e.g. an unknown ref). Only 0 and 1 are verdicts; anything else is
|
|
42
|
+
* UNKNOWN, and the CLI treats UNKNOWN as a failure.
|
|
43
|
+
*/
|
|
44
|
+
function checkAncestry({ ancestor, descendant, git = defaultGit }) {
|
|
45
|
+
const { status } = git(['merge-base', '--is-ancestor', ancestor, descendant]);
|
|
46
|
+
if (status === 0) return ANCESTRY_VERDICT.ANCESTOR;
|
|
47
|
+
if (status === 1) return ANCESTRY_VERDICT.DIVERGED;
|
|
48
|
+
return ANCESTRY_VERDICT.UNKNOWN;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseArgs(argv) {
|
|
52
|
+
const out = { ancestor: 'origin/main', descendant: 'origin/next' };
|
|
53
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
54
|
+
const flag = argv[i];
|
|
55
|
+
const value = argv[i + 1];
|
|
56
|
+
if ((flag === '--ancestor' || flag === '--descendant') && typeof value === 'string' && REF_RE.test(value)) {
|
|
57
|
+
out[flag.slice(2)] = value;
|
|
58
|
+
i += 1;
|
|
59
|
+
} else {
|
|
60
|
+
return { error: `unexpected argument: ${flag}` };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function main(argv = process.argv.slice(2), { git = defaultGit, stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
67
|
+
const args = parseArgs(argv);
|
|
68
|
+
if (args.error) {
|
|
69
|
+
stderr.write(`::error::${args.error}\n`);
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
const verdict = checkAncestry({ ancestor: args.ancestor, descendant: args.descendant, git });
|
|
73
|
+
stdout.write(`${JSON.stringify({ verdict, ancestor: args.ancestor, descendant: args.descendant })}\n`);
|
|
74
|
+
if (verdict === ANCESTRY_VERDICT.ANCESTOR) return 0;
|
|
75
|
+
stderr.write(verdict === ANCESTRY_VERDICT.DIVERGED
|
|
76
|
+
? `::error::${args.ancestor} is not an ancestor of ${args.descendant}, so the next release -> main merge will conflict (#5894). `
|
|
77
|
+
+ 'Land a back-merge of main into next as a merge commit first: push chore/backmerge-main-to-next-<short sha of main> '
|
|
78
|
+
+ '(next plus `git merge -s ours --no-ff origin/main`), open it against next, and merge it with '
|
|
79
|
+
+ '`node scripts/merge-commit-pr.cjs <PR>` as a repo admin. Never send it through the merge queue.\n'
|
|
80
|
+
: `::error::could not read the ancestry of ${args.ancestor} and ${args.descendant} (git failed). Fetch both refs and retry.\n`);
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (require.main === module) runMain(main);
|
|
85
|
+
|
|
86
|
+
module.exports = { ANCESTRY_VERDICT, GIT_TIMEOUT_MS, checkAncestry, parseArgs, main };
|
|
@@ -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
|
+
};
|