@itcase/config 1.7.5 → 1.7.7
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@itcase/config",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.7",
|
|
4
4
|
"author": "ITCase",
|
|
5
5
|
"description": "ITCase Config",
|
|
6
6
|
"engines": {
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"@rollup/plugin-terser": "^1.0.0",
|
|
58
58
|
"@rollup/plugin-typescript": "^12.3.0",
|
|
59
59
|
"@semantic-release/changelog": "^7.0.0",
|
|
60
|
+
"@semantic-release/exec": "^7.1.0",
|
|
60
61
|
"@semantic-release/git": "^11.0.1",
|
|
61
62
|
"@semantic-release/release-notes-generator": "14.1.1",
|
|
62
63
|
"@svgr/plugin-jsx": "^8.1.0",
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/* eslint-disable perfectionist/sort-objects */
|
|
2
2
|
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
3
6
|
import {
|
|
4
7
|
CHANGELOG_FILE,
|
|
5
8
|
COMMITS_SORT,
|
|
@@ -9,6 +12,20 @@ import {
|
|
|
9
12
|
releaseRules,
|
|
10
13
|
} from './config.js'
|
|
11
14
|
|
|
15
|
+
const issuesReleaseNotesScript = join(
|
|
16
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
17
|
+
'issuesReleaseNotes.js',
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
// Ссылки на задачи из PR. Репозиторий задач и токен приходят из CI:
|
|
21
|
+
// RELEASE_NOTES_ISSUES_REPO, RELEASE_NOTES_GH_TOKEN.
|
|
22
|
+
const issuesReleaseNotesPlugin = [
|
|
23
|
+
'@semantic-release/exec',
|
|
24
|
+
{
|
|
25
|
+
generateNotesCmd: `node ${JSON.stringify(issuesReleaseNotesScript)} "\${lastRelease.gitTag}" "\${nextRelease.gitTag}"`,
|
|
26
|
+
},
|
|
27
|
+
]
|
|
28
|
+
|
|
12
29
|
const releaseNotesGeneratorConfig = {
|
|
13
30
|
preset: PRESET,
|
|
14
31
|
parserOpts: { noteKeywords: releaseNoteKeywords },
|
|
@@ -70,7 +87,7 @@ const commonPlugins = {
|
|
|
70
87
|
* @param {Object|null} options.npmConfig - Конфигурация для @semantic-release/npm. Если null, плагин не добавляется
|
|
71
88
|
* @param {Object} options.gitConfig - Конфигурация для @semantic-release/git
|
|
72
89
|
* @param {Object} [options.githubConfig] - Доп. опции для @semantic-release/github (например assets)
|
|
73
|
-
* @param {Array} options.additionalPlugins - Дополнительные плагины перед
|
|
90
|
+
* @param {Array} options.additionalPlugins - Дополнительные плагины перед генератором коммитов: их generateNotes идёт первым
|
|
74
91
|
* @param {Array} [options.preparePlugins] - Плагины после npm и перед git (prepare-шаг)
|
|
75
92
|
* @param {boolean} options.skipReleaseNotesGenerator - Пропустить @semantic-release/release-notes-generator
|
|
76
93
|
* @returns {Object} Конфигурация semantic-release
|
|
@@ -86,12 +103,13 @@ function createSemanticReleaseConfig({
|
|
|
86
103
|
}) {
|
|
87
104
|
const plugins = [commonPlugins.commitAnalyzer]
|
|
88
105
|
|
|
106
|
+
// Задачи важнее коммитов: их текст идёт в заметках первым.
|
|
107
|
+
plugins.push(...additionalPlugins)
|
|
108
|
+
|
|
89
109
|
if (!skipReleaseNotesGenerator) {
|
|
90
110
|
plugins.push(commonPlugins.releaseNotesGenerator)
|
|
91
111
|
}
|
|
92
112
|
|
|
93
|
-
plugins.push(...additionalPlugins)
|
|
94
|
-
|
|
95
113
|
plugins.push(commonPlugins.changelog)
|
|
96
114
|
|
|
97
115
|
if (npmConfig !== null) {
|
|
@@ -114,5 +132,6 @@ function createSemanticReleaseConfig({
|
|
|
114
132
|
export {
|
|
115
133
|
commonPlugins,
|
|
116
134
|
createSemanticReleaseConfig,
|
|
135
|
+
issuesReleaseNotesPlugin,
|
|
117
136
|
releaseNotesGeneratorConfig,
|
|
118
137
|
}
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* process.argv:
|
|
8
|
+
* - 0 index: "node" executable path.
|
|
9
|
+
* - 1 index: Script file path.
|
|
10
|
+
* - 2+ indexes: Custom arguments passed from "semantic-release/exec".
|
|
11
|
+
*
|
|
12
|
+
* We slice from index 2 to keep only release-related args.
|
|
13
|
+
* See "common.js".
|
|
14
|
+
*/
|
|
15
|
+
const [lastTagArg, nextTagArg] = process.argv.slice(2)
|
|
16
|
+
|
|
17
|
+
// Token is passed from GitHub Actions env and used for all API calls
|
|
18
|
+
const githubToken = process.env.RELEASE_NOTES_GH_TOKEN
|
|
19
|
+
|
|
20
|
+
const currentProjectOwner = process.env.GITHUB_REPOSITORY?.split('/')[0]
|
|
21
|
+
const currentProjectRepo = process.env.GITHUB_REPOSITORY?.split('/')[1]
|
|
22
|
+
|
|
23
|
+
// Repository with all issues. Not this project repository.
|
|
24
|
+
// Used when PR text contains "Issue: #123".
|
|
25
|
+
const releaseNotesIssuesRepo = process.env.RELEASE_NOTES_ISSUES_REPO
|
|
26
|
+
|
|
27
|
+
const issuesProjectOwner = releaseNotesIssuesRepo?.split('/')[0]
|
|
28
|
+
const issuesProjectRepo = releaseNotesIssuesRepo?.split('/')[1]
|
|
29
|
+
|
|
30
|
+
if (!currentProjectOwner || !currentProjectRepo || !githubToken) {
|
|
31
|
+
throw new Error('Missing "GITHUB_REPOSITORY" or "RELEASE_NOTES_GH_TOKEN"')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!issuesProjectOwner || !issuesProjectRepo) {
|
|
35
|
+
throw new Error('Missing "RELEASE_NOTES_ISSUES_REPO"')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Only issues from this repo belong in release notes (ignore dependabot/changelog refs).
|
|
39
|
+
const allowedIssuesRepo = `${issuesProjectOwner}/${issuesProjectRepo}`
|
|
40
|
+
|
|
41
|
+
// Load issues in small batches to avoid API request spikes.
|
|
42
|
+
const ISSUE_LOAD_CONCURRENCY = 10
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Allowed PR target branches used to filter associated PRs from commit lookups.
|
|
46
|
+
*/
|
|
47
|
+
const allowedPrBaseBranches = [
|
|
48
|
+
'develop',
|
|
49
|
+
'master',
|
|
50
|
+
'docker/develop',
|
|
51
|
+
'docker/master',
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
// "semantic-release" can pass "null" string when there is no previous tag.
|
|
55
|
+
const lastTag = lastTagArg && lastTagArg !== 'null' ? lastTagArg : null
|
|
56
|
+
const nextTag = nextTagArg && nextTagArg !== 'null' ? nextTagArg : null
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Matches issue markers in PR body, for example:
|
|
60
|
+
* - `issue: itcase/agro-management-pps#123`;
|
|
61
|
+
* - `ref: #123`;
|
|
62
|
+
* - `closes owner/repo#123`;
|
|
63
|
+
*/
|
|
64
|
+
const issueRefRegex =
|
|
65
|
+
/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|refs?|issue)\s*:?\s*(?:(?<owner>[A-Za-z0-9_.-]+)\/(?<repo>[A-Za-z0-9_.-]+))?#(?<number>\d+)\b/gi
|
|
66
|
+
|
|
67
|
+
// Matches full repository references anywhere in body: "owner/repo#123".
|
|
68
|
+
const fullRepoIssueRegex =
|
|
69
|
+
/\b(?<owner>[A-Za-z0-9_.-]+)\/(?<repo>[A-Za-z0-9_.-]+)#(?<number>\d+)\b/gi
|
|
70
|
+
|
|
71
|
+
// Matches full GitHub issue URLs, for example:
|
|
72
|
+
// "https://github.com/itcase/agro-management-pps/issues/123".
|
|
73
|
+
const githubIssueUrlRegex =
|
|
74
|
+
/\bhttps?:\/\/github\.com\/(?<owner>[A-Za-z0-9_.-]+)\/(?<repo>[A-Za-z0-9_.-]+)\/issues\/(?<number>\d+)\b/gi
|
|
75
|
+
|
|
76
|
+
let githubApiCallsCount = 0
|
|
77
|
+
// Track minimal remaining budget across all API calls in one run
|
|
78
|
+
let githubApiMinRemaining = Number.POSITIVE_INFINITY
|
|
79
|
+
let githubApiLastRemaining = null
|
|
80
|
+
let githubApiLastReset = null
|
|
81
|
+
|
|
82
|
+
async function run() {
|
|
83
|
+
/**
|
|
84
|
+
* https://www.npmjs.com/package/@semantic-release/exec
|
|
85
|
+
* generateNotesCmd:
|
|
86
|
+
* - `stdout`: Only the release note must be written to stdout.
|
|
87
|
+
* - `stderr`: Can be used for logging.
|
|
88
|
+
*/
|
|
89
|
+
console.error(
|
|
90
|
+
`[release-notes] Input args: lastTagArg="${lastTagArg}", nextTagArg="${nextTagArg}", lastTag="${lastTag}", nextTag="${nextTag}"`,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
// First release or missing tags: commit notes from semantic-release stay as-is.
|
|
94
|
+
if (!lastTag || !nextTag) {
|
|
95
|
+
console.error(
|
|
96
|
+
'[release-notes] Exit: Missing "lastTag" or "nextTag" arguments',
|
|
97
|
+
)
|
|
98
|
+
process.stdout.write('')
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const cachePath = getCachePath(lastTag, nextTag)
|
|
103
|
+
const cachedNotes = readCachedNotes(cachePath)
|
|
104
|
+
if (cachedNotes !== null) {
|
|
105
|
+
console.error(`[release-notes] Cache hit: ${cachePath}`)
|
|
106
|
+
process.stdout.write(cachedNotes)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// "nextTag" may not exist yet during generateNotes step.
|
|
111
|
+
// Build range from previous release tag to current HEAD state.
|
|
112
|
+
const fromDateIso = getGitRefDate(lastTag)
|
|
113
|
+
const toDateIso = getGitRefDate('HEAD')
|
|
114
|
+
|
|
115
|
+
console.error(
|
|
116
|
+
`[release-notes] Processing release range: from ${lastTag} (${fromDateIso}) to HEAD (${toDateIso})`,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* IMPORTANT:
|
|
121
|
+
* Release notes are based on commits that actually entered the release
|
|
122
|
+
* branch in range <lastTag..HEAD>, not on PR "merged_at"
|
|
123
|
+
* dates by search API (/search/issues).
|
|
124
|
+
*
|
|
125
|
+
* Why:
|
|
126
|
+
* PR can be merged into "develop" earlier, but reach "docker/master" only later.
|
|
127
|
+
* If we filter only by PR merge date, we can miss changes that are in release,
|
|
128
|
+
* or include changes that are not yet in release branch.
|
|
129
|
+
*
|
|
130
|
+
* For example:
|
|
131
|
+
* We merge some PR to "develop". After some time we create release
|
|
132
|
+
* from "docker/develop" to "docker/master". PR is merged into "develop"
|
|
133
|
+
* and NOT released yet, date of merge will be earlier than new release.
|
|
134
|
+
* On second release we will merge this PR to "docker/master" from "develop"
|
|
135
|
+
* and check it by included commits, not time of merge this PR.
|
|
136
|
+
*/
|
|
137
|
+
const releaseCommitsList = getGitCommitsInRange(lastTag, 'HEAD')
|
|
138
|
+
// Deduplicate SHAs before commit->PR lookups to avoid repeated API calls
|
|
139
|
+
// when the git log output contains duplicated commits (perhaps in some cases)
|
|
140
|
+
const uniqueCommitShasList = [
|
|
141
|
+
...new Set(releaseCommitsList.map((commit) => commit.sha)),
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
console.error(
|
|
145
|
+
`[release-notes] Commits in release range: total=${releaseCommitsList.length}, unique=${uniqueCommitShasList.length}`,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
const associatedPullRequestsMap = new Map()
|
|
149
|
+
let pullRequestLookups = 0
|
|
150
|
+
for (const commitSha of uniqueCommitShasList) {
|
|
151
|
+
// Ask GitHub which PRs are associated with each commit in the release range.
|
|
152
|
+
const associatedPullRequestsList =
|
|
153
|
+
await getAssociatedPullRequestsForCommit(commitSha)
|
|
154
|
+
pullRequestLookups += 1
|
|
155
|
+
|
|
156
|
+
for (const pullRequest of associatedPullRequestsList) {
|
|
157
|
+
// Keep PRs unique by PR number.
|
|
158
|
+
associatedPullRequestsMap.set(pullRequest.number, pullRequest)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const associatedPullRequestsList = [...associatedPullRequestsMap.values()]
|
|
162
|
+
/**
|
|
163
|
+
* GitHub can associate the same commit with many PRs (for example when branches
|
|
164
|
+
* are regularly synced with "develop"). Without filtering, this produces false
|
|
165
|
+
* issue links in release notes.
|
|
166
|
+
*
|
|
167
|
+
* We keep only "release-relevant" PRs:
|
|
168
|
+
* - merged PRs only ("closed" + "merged_at")
|
|
169
|
+
* - merged into an allowed base branch for this release flow
|
|
170
|
+
*/
|
|
171
|
+
const pullRequestFilterStats = {
|
|
172
|
+
excludedByBaseBranch: 0,
|
|
173
|
+
excludedNotMerged: 0,
|
|
174
|
+
included: 0,
|
|
175
|
+
}
|
|
176
|
+
const filteredPullRequestsList = associatedPullRequestsList.filter(
|
|
177
|
+
(pullRequest) => {
|
|
178
|
+
if (!pullRequest.merged_at || pullRequest.state !== 'closed') {
|
|
179
|
+
pullRequestFilterStats.excludedNotMerged += 1
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const baseRef = pullRequest.base?.ref
|
|
184
|
+
// Keep only PRs merged into the release flow branch.
|
|
185
|
+
if (!baseRef || !allowedPrBaseBranches.includes(baseRef)) {
|
|
186
|
+
pullRequestFilterStats.excludedByBaseBranch += 1
|
|
187
|
+
return false
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
pullRequestFilterStats.included += 1
|
|
191
|
+
return true
|
|
192
|
+
},
|
|
193
|
+
)
|
|
194
|
+
console.error(
|
|
195
|
+
`[release-notes] Associated PR lookups: ${pullRequestLookups}, unique associated PRs: ${associatedPullRequestsList.length}, included after filters: ${filteredPullRequestsList.length}`,
|
|
196
|
+
)
|
|
197
|
+
console.error(
|
|
198
|
+
`[release-notes] PR filter stats: ${JSON.stringify(pullRequestFilterStats)};`,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
// Use "Set" to deduplicate issue references collected from PRs and commits.
|
|
202
|
+
const issueRefsSet = new Set()
|
|
203
|
+
for (const pullRequest of filteredPullRequestsList) {
|
|
204
|
+
extractIssueRefs(pullRequest.title, issueRefsSet)
|
|
205
|
+
extractIssueRefs(pullRequest.body, issueRefsSet)
|
|
206
|
+
}
|
|
207
|
+
for (const commitInfo of releaseCommitsList) {
|
|
208
|
+
extractIssueRefs(commitInfo.subject, issueRefsSet)
|
|
209
|
+
extractIssueRefs(commitInfo.body, issueRefsSet)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
console.error(
|
|
213
|
+
`[release-notes] Unique issue refs extracted from PR+commits: ${issueRefsSet.size}`,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if (issueRefsSet.size === 0) {
|
|
217
|
+
emitNotes(cachePath, '')
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const allIssueRefsList = [...issueRefsSet]
|
|
222
|
+
const issueRefsList = allIssueRefsList.filter((ref) =>
|
|
223
|
+
ref.startsWith(`${allowedIssuesRepo}#`),
|
|
224
|
+
)
|
|
225
|
+
const excludedForeignRefsCount =
|
|
226
|
+
allIssueRefsList.length - issueRefsList.length
|
|
227
|
+
|
|
228
|
+
if (excludedForeignRefsCount > 0) {
|
|
229
|
+
const excludedRefsList = allIssueRefsList.filter(
|
|
230
|
+
(ref) => !ref.startsWith(`${allowedIssuesRepo}#`),
|
|
231
|
+
)
|
|
232
|
+
console.error(
|
|
233
|
+
`[release-notes] Excluded ${excludedForeignRefsCount} foreign issue refs (dependabot/changelog noise): ${excludedRefsList.join(', ')}`,
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (issueRefsList.length === 0) {
|
|
238
|
+
emitNotes(cachePath, '')
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
console.error(
|
|
243
|
+
`[release-notes] Issue refs to load (${allowedIssuesRepo}): ${issueRefsList.join(', ')}`,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
const issuesList = await loadIssuesInBatches(issueRefsList)
|
|
247
|
+
|
|
248
|
+
// Issues endpoint can also return PRs("issue-like" entities)
|
|
249
|
+
const lines = issuesList
|
|
250
|
+
// Exclude pull requests from release notes
|
|
251
|
+
.filter((issue) => !issue.pull_request)
|
|
252
|
+
.sort((first, second) => first.number - second.number)
|
|
253
|
+
.map((issue) => `- ${issue.title} [#${issue.number}](${issue.html_url})`)
|
|
254
|
+
|
|
255
|
+
console.error(
|
|
256
|
+
`[release-notes] Issues loaded: ${issuesList.length}, issues included in release notes: ${lines.length}`,
|
|
257
|
+
)
|
|
258
|
+
console.error(
|
|
259
|
+
`[release-notes] GitHub API usage: calls=${githubApiCallsCount}, minRateRemaining=${githubApiMinRemaining === Number.POSITIVE_INFINITY ? 'n/a' : githubApiMinRemaining}, lastRateRemaining=${githubApiLastRemaining ?? 'n/a'}, lastRateReset=${githubApiLastReset ?? 'n/a'}`,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if (lines.length === 0) {
|
|
263
|
+
emitNotes(cachePath, '')
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const releaseNotes = `## Что изменилось?\n\n${lines.join('\n')}\n`
|
|
268
|
+
emitNotes(cachePath, releaseNotes)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Small helper for GitHub REST API with common headers and query serialization.
|
|
272
|
+
async function fetchGithubApi(pathname, queryParams = {}) {
|
|
273
|
+
const apiUrl = new URL(`https://api.github.com${pathname}`)
|
|
274
|
+
|
|
275
|
+
for (const [key, value] of Object.entries(queryParams)) {
|
|
276
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
277
|
+
apiUrl.searchParams.set(key, String(value))
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// https://docs.github.com/ru/rest/about-the-rest-api/api-versions?apiVersion=2026-03-10#supported-api-versions
|
|
282
|
+
const response = await fetch(apiUrl, {
|
|
283
|
+
headers: {
|
|
284
|
+
Accept: 'application/vnd.github+json',
|
|
285
|
+
Authorization: `Bearer ${githubToken}`,
|
|
286
|
+
'User-Agent': 'semantic-release-issues-notes',
|
|
287
|
+
// GitHub REST API version header
|
|
288
|
+
'X-GitHub-Api-Version': '2026-03-10',
|
|
289
|
+
},
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
if (!response.ok) {
|
|
293
|
+
const { status, statusText } = response
|
|
294
|
+
const message = await response.text()
|
|
295
|
+
const serializedQuery = JSON.stringify(queryParams)
|
|
296
|
+
throw new Error(
|
|
297
|
+
`GitHub API request failed: ${statusText} (${status}) - ${message}; path="${pathname}"; url="${apiUrl.toString()}"; query=${serializedQuery}`,
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Successful response: collect basic rate-limit diagnostics for CI logs
|
|
302
|
+
githubApiCallsCount += 1
|
|
303
|
+
const rateRemainingHeader = response.headers.get('x-ratelimit-remaining')
|
|
304
|
+
const rateResetHeader = response.headers.get('x-ratelimit-reset')
|
|
305
|
+
const rateRemaining = Number(rateRemainingHeader)
|
|
306
|
+
if (!Number.isNaN(rateRemaining)) {
|
|
307
|
+
githubApiLastRemaining = rateRemaining
|
|
308
|
+
githubApiMinRemaining = Math.min(githubApiMinRemaining, rateRemaining)
|
|
309
|
+
}
|
|
310
|
+
if (rateResetHeader) {
|
|
311
|
+
// API returns UNIX seconds, convert to ISO for readable logs
|
|
312
|
+
githubApiLastReset = new Date(Number(rateResetHeader) * 1000).toISOString()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return response.json()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function getGitRefDate(refName) {
|
|
319
|
+
try {
|
|
320
|
+
// Returns date in ISO format, e.g. "2026-03-24T12:25:32+00:00"
|
|
321
|
+
const dateIso = execSync(`git log -1 --format=%cI ${refName}`, {
|
|
322
|
+
encoding: 'utf-8',
|
|
323
|
+
})
|
|
324
|
+
return dateIso.trim()
|
|
325
|
+
} catch (error) {
|
|
326
|
+
throw new Error(`Failed to read git date for ref "${refName}"`, {
|
|
327
|
+
cause: error,
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function extractIssueRefs(text, refs) {
|
|
333
|
+
if (!text) {
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Marker-based extraction (Issue/Refs/Fixes/Closes/etc).
|
|
338
|
+
for (const match of text.matchAll(issueRefRegex)) {
|
|
339
|
+
const issueOwner = match.groups?.owner || issuesProjectOwner
|
|
340
|
+
const issueRepo = match.groups?.repo || issuesProjectRepo
|
|
341
|
+
const issueNumber = match.groups?.number
|
|
342
|
+
|
|
343
|
+
if (!issueOwner || !issueRepo || !issueNumber) {
|
|
344
|
+
continue
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
refs.add(`${issueOwner}/${issueRepo}#${issueNumber}`)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Fallback extraction for explicit full refs: owner/repo#number.
|
|
351
|
+
for (const match of text.matchAll(fullRepoIssueRegex)) {
|
|
352
|
+
const issueOwner = match.groups?.owner
|
|
353
|
+
const issueRepo = match.groups?.repo
|
|
354
|
+
const issueNumber = match.groups?.number
|
|
355
|
+
|
|
356
|
+
if (!issueOwner || !issueRepo || !issueNumber) {
|
|
357
|
+
continue
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
refs.add(`${issueOwner}/${issueRepo}#${issueNumber}`)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Also support full GitHub issue URLs copied from browser address bar.
|
|
364
|
+
for (const match of text.matchAll(githubIssueUrlRegex)) {
|
|
365
|
+
const issueOwner = match.groups?.owner
|
|
366
|
+
const issueRepo = match.groups?.repo
|
|
367
|
+
const issueNumber = match.groups?.number
|
|
368
|
+
|
|
369
|
+
if (!issueOwner || !issueRepo || !issueNumber) {
|
|
370
|
+
continue
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
refs.add(`${issueOwner}/${issueRepo}#${issueNumber}`)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function getGitCommitsInRange(fromRef, toRef) {
|
|
378
|
+
/**
|
|
379
|
+
* We serialize each commit with `git log --format=%H%x1f%s%x1f%b%x1e`.
|
|
380
|
+
*
|
|
381
|
+
* Field mapping:
|
|
382
|
+
* - `%H` -> sha
|
|
383
|
+
* - `%s` -> subject
|
|
384
|
+
* - `%b` -> body
|
|
385
|
+
*
|
|
386
|
+
* Separators:
|
|
387
|
+
* - `\x1f` separates fields inside one commit record
|
|
388
|
+
* - `\x1e` separates commit records
|
|
389
|
+
*
|
|
390
|
+
* We use ASCII control separators to safely parse multiline commit bodies and avoid
|
|
391
|
+
* ambiguity with regular punctuation/symbols that can appear in commit messages.
|
|
392
|
+
*
|
|
393
|
+
* About commit message structure:
|
|
394
|
+
* - `subject` is the first line of the commit message
|
|
395
|
+
* - `body` is the text after an empty line, if present
|
|
396
|
+
*
|
|
397
|
+
* Example:
|
|
398
|
+
* - commit message: `feat(report): add table summary`
|
|
399
|
+
* - `subject`: `feat(report): add table summary`
|
|
400
|
+
* - `body`: empty string
|
|
401
|
+
*
|
|
402
|
+
* In this format `%s` is always the subject, while `%b` may be empty and that is valid.
|
|
403
|
+
*
|
|
404
|
+
* After splitting one record by `\x1f`, a healthy record should contain at least
|
|
405
|
+
* 3 fields: [sha, subject, body]. We keep `.filter((fields) => fields.length >= 3)`
|
|
406
|
+
* as a guard against malformed/partial chunks (for example, tail artifacts after split)
|
|
407
|
+
* so that downstream destructuring remains safe.
|
|
408
|
+
*/
|
|
409
|
+
const rawOutput = execSync(
|
|
410
|
+
`git log --format=%H%x1f%s%x1f%b%x1e ${fromRef}..${toRef}`,
|
|
411
|
+
{ encoding: 'utf-8' },
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
return rawOutput
|
|
415
|
+
.split('\x1e')
|
|
416
|
+
.filter(Boolean)
|
|
417
|
+
.map((record) => record.split('\x1f'))
|
|
418
|
+
.filter((fields) => fields.length >= 3)
|
|
419
|
+
.map(([sha, subject, body]) => ({
|
|
420
|
+
body: body?.trim() || '',
|
|
421
|
+
sha: sha.trim(),
|
|
422
|
+
subject: subject?.trim() || '',
|
|
423
|
+
}))
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function getAssociatedPullRequestsForCommit(commitSha) {
|
|
427
|
+
// GitHub endpoint that returns PRs linked to a commit SHA.
|
|
428
|
+
// Works for both merge-commit and regular commits associated with PRs.
|
|
429
|
+
const response = await fetchGithubApi(
|
|
430
|
+
`/repos/${currentProjectOwner}/${currentProjectRepo}/commits/${commitSha}/pulls`,
|
|
431
|
+
)
|
|
432
|
+
return Array.isArray(response) ? response : []
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function loadIssuesInBatches(refs) {
|
|
436
|
+
const issuesList = []
|
|
437
|
+
|
|
438
|
+
for (let index = 0; index < refs.length; index += ISSUE_LOAD_CONCURRENCY) {
|
|
439
|
+
const batch = refs.slice(index, index + ISSUE_LOAD_CONCURRENCY)
|
|
440
|
+
const batchResults = await Promise.all(batch.map(loadIssue))
|
|
441
|
+
issuesList.push(...batchResults)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return issuesList
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function loadIssue(ref) {
|
|
448
|
+
const [repoRef, issueNumber] = ref.split('#')
|
|
449
|
+
const [issueOwner, issueRepo] = repoRef.split('/')
|
|
450
|
+
const issuePath = `/repos/${issueOwner}/${issueRepo}/issues/${issueNumber}`
|
|
451
|
+
try {
|
|
452
|
+
return await fetchGithubApi(issuePath)
|
|
453
|
+
} catch (error) {
|
|
454
|
+
throw new Error(
|
|
455
|
+
`Failed to load issue for ref="${ref}" using path="${issuePath}"`,
|
|
456
|
+
{ cause: error },
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function getCachePath(lastTag, nextTag) {
|
|
462
|
+
const runId = process.env.GITHUB_RUN_ID || 'local'
|
|
463
|
+
const safeLastTag = lastTag.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
464
|
+
const safeNextTag = nextTag.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
465
|
+
const cacheDir = process.env.RUNNER_TEMP || tmpdir()
|
|
466
|
+
|
|
467
|
+
return join(
|
|
468
|
+
cacheDir,
|
|
469
|
+
`release-notes-${runId}-${safeLastTag}-${safeNextTag}.md`,
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function readCachedNotes(cachePath) {
|
|
474
|
+
if (!existsSync(cachePath)) {
|
|
475
|
+
return null
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return readFileSync(cachePath, 'utf-8')
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function writeCachedNotes(cachePath, notes) {
|
|
482
|
+
writeFileSync(cachePath, notes, 'utf-8')
|
|
483
|
+
console.error(`[release-notes] Cached notes: ${cachePath}`)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function emitNotes(cachePath, notes) {
|
|
487
|
+
writeCachedNotes(cachePath, notes)
|
|
488
|
+
process.stdout.write(notes)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
run().catch((error) => {
|
|
492
|
+
console.error(error)
|
|
493
|
+
process.exit(1)
|
|
494
|
+
})
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createSemanticReleaseConfig,
|
|
3
|
+
issuesReleaseNotesPlugin,
|
|
4
|
+
} from './common.js'
|
|
2
5
|
|
|
3
6
|
const DEFAULT_VERSION_ASSETS = [
|
|
4
7
|
'package.json',
|
|
@@ -15,6 +18,7 @@ const DEFAULT_RELEASE_ASSETS = ['builds/*.apk']
|
|
|
15
18
|
* - Не публикует в npm
|
|
16
19
|
* - Коммитит package + Android version-файлы
|
|
17
20
|
* - Прикрепляет к GitHub release APK из `builds/` (если есть; отсутствующие пропускаются)
|
|
21
|
+
* - Заметки релиза: сначала задачи из issuesReleaseNotes.js, если они есть, потом обычные коммиты
|
|
18
22
|
*
|
|
19
23
|
* Сборку APK делайте в prepare проекта или отдельным CI job до publish.
|
|
20
24
|
*
|
|
@@ -35,6 +39,7 @@ function createSemanticReleaseApp({
|
|
|
35
39
|
versionAssets = DEFAULT_VERSION_ASSETS,
|
|
36
40
|
} = {}) {
|
|
37
41
|
return createSemanticReleaseConfig({
|
|
42
|
+
additionalPlugins: [issuesReleaseNotesPlugin],
|
|
38
43
|
branches: branches,
|
|
39
44
|
gitConfig: {
|
|
40
45
|
message:
|
|
@@ -3,10 +3,7 @@
|
|
|
3
3
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { resolve } from 'node:path'
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
createSemanticReleaseConfig,
|
|
8
|
-
releaseNotesGeneratorConfig,
|
|
9
|
-
} from './common.js'
|
|
6
|
+
import { createSemanticReleaseConfig } from './common.js'
|
|
10
7
|
|
|
11
8
|
const RELEASE_ASSETS = [
|
|
12
9
|
'lerna.json',
|
|
@@ -44,17 +41,14 @@ const semanticReleaseMonorepo = createSemanticReleaseConfig({
|
|
|
44
41
|
[
|
|
45
42
|
'semantic-release-lerna',
|
|
46
43
|
{
|
|
47
|
-
|
|
44
|
+
// Заметки lerna для монорепо выходят пустыми. Их пишет
|
|
45
|
+
// @semantic-release/release-notes-generator, как в обычных пакетах.
|
|
46
|
+
generateNotes: false,
|
|
48
47
|
latch: false,
|
|
49
|
-
...releaseNotesGeneratorConfig,
|
|
50
|
-
host: 'https://github.com',
|
|
51
|
-
commit: 'commit',
|
|
52
|
-
issue: 'issues',
|
|
53
48
|
},
|
|
54
49
|
],
|
|
55
50
|
],
|
|
56
51
|
preparePlugins: [keepLernaIndependentPlugin],
|
|
57
|
-
skipReleaseNotesGenerator: true,
|
|
58
52
|
})
|
|
59
53
|
|
|
60
54
|
export { semanticReleaseMonorepo }
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createSemanticReleaseConfig,
|
|
3
|
+
issuesReleaseNotesPlugin,
|
|
4
|
+
} from './common.js'
|
|
2
5
|
|
|
3
6
|
const semanticReleaseProject = createSemanticReleaseConfig({
|
|
7
|
+
additionalPlugins: [issuesReleaseNotesPlugin],
|
|
4
8
|
branches: ['docker/master'],
|
|
5
9
|
gitConfig: {
|
|
6
10
|
message: 'release: publishing new version ${nextRelease.version} [skip ci]',
|