@reelout/core 0.1.1 → 0.2.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/dist/analyzer.js +38 -0
- package/dist/analyzer.js.map +1 -0
- package/dist/builder.js +52 -0
- package/dist/builder.js.map +1 -0
- package/dist/changelog.js +58 -0
- package/dist/changelog.js.map +1 -0
- package/dist/formatError-BXNdyRfu.js +23 -0
- package/dist/formatError-BXNdyRfu.js.map +1 -0
- package/dist/index.js +175 -1
- package/dist/index.js.map +1 -0
- package/dist/types-la_KkjCS.js +1 -0
- package/dist/types.js +2 -0
- package/dist/workflow-BoVmbvH6.js +2342 -0
- package/dist/workflow-BoVmbvH6.js.map +1 -0
- package/package.json +27 -3
- package/src/analyzer.ts +63 -0
- package/src/builder.ts +104 -0
- package/src/cache/TTLCache.ts +100 -0
- package/src/cache/index.ts +4 -0
- package/src/changelog.ts +97 -0
- package/src/config/const.ts +97 -0
- package/src/config/index.ts +11 -0
- package/src/config/isSemVerGitTag.ts +25 -0
- package/src/config/normalizeCommitFilter.ts +37 -0
- package/src/config/normalizeReleaseRule.ts +42 -0
- package/src/config/normalizeWorkspace.ts +34 -0
- package/src/config/resolvePlugin.ts +56 -0
- package/src/config/resolveReleaseTarget.ts +71 -0
- package/src/config/sanitizePrereleaseLabel.ts +18 -0
- package/src/git/branch/getCurrentGitBranch.ts +18 -0
- package/src/git/branch/index.ts +4 -0
- package/src/git/commit/createGitCommit.ts +29 -0
- package/src/git/commit/extractMentions.ts +34 -0
- package/src/git/commit/getCurrentGitCommit.ts +14 -0
- package/src/git/commit/getParsedCommit.ts +53 -0
- package/src/git/commit/index.ts +9 -0
- package/src/git/commit/parseCommit.ts +99 -0
- package/src/git/commit/parseGitPrettyFormat.ts +76 -0
- package/src/git/history/countGitHistory.ts +28 -0
- package/src/git/history/expandGitHistory.ts +108 -0
- package/src/git/history/index.ts +9 -0
- package/src/git/history/mergeGitHistory.ts +76 -0
- package/src/git/history/restoreFullGitHistory.ts +19 -0
- package/src/git/history/showGitHistory.ts +80 -0
- package/src/git/history/showParsedGitHistory.ts +30 -0
- package/src/git/index.ts +9 -0
- package/src/git/repository/ensureGitUserConfig.ts +31 -0
- package/src/git/repository/getGitConfigValue.ts +27 -0
- package/src/git/repository/getGitRemote.ts +20 -0
- package/src/git/repository/index.ts +10 -0
- package/src/git/repository/isGitShallowRepository.ts +21 -0
- package/src/git/repository/pushGitChanges.ts +25 -0
- package/src/git/repository/resolveGitRepository.ts +40 -0
- package/src/git/repository/withShallowLock.ts +62 -0
- package/src/git/revision/detectGitRelationship.ts +43 -0
- package/src/git/revision/index.ts +7 -0
- package/src/git/revision/isGitAncestor.ts +33 -0
- package/src/git/revision/readGitFile.ts +29 -0
- package/src/git/revision/resolveGitRevision.ts +23 -0
- package/src/git/tag/createGitTag.ts +25 -0
- package/src/git/tag/deleteGitTag.ts +17 -0
- package/src/git/tag/ensureGitTag.ts +63 -0
- package/src/git/tag/generateGitTagName.ts +43 -0
- package/src/git/tag/getCurrentGitTag.ts +23 -0
- package/src/git/tag/getGitTagMessage.ts +35 -0
- package/src/git/tag/getLatestGitTag.ts +22 -0
- package/src/git/tag/index.ts +13 -0
- package/src/git/tag/isGitTagConnected.ts +33 -0
- package/src/git/tag/isGitTagPointed.ts +38 -0
- package/src/git/tag/isGitTagPresent.ts +17 -0
- package/src/index.ts +8 -2
- package/src/types.ts +217 -0
- package/src/util/buildTrailerPattern.ts +20 -0
- package/src/util/compact.ts +14 -0
- package/src/util/execCommand.ts +130 -0
- package/src/util/execGit.ts +18 -0
- package/src/util/formatError.ts +21 -0
- package/src/util/formatFile.ts +41 -0
- package/src/util/index.ts +16 -0
- package/src/util/interpolate.ts +141 -0
- package/src/util/isCI.ts +23 -0
- package/src/util/isDefined.ts +12 -0
- package/src/util/isMatch.ts +43 -0
- package/src/util/readJson.ts +47 -0
- package/src/util/unique.ts +26 -0
- package/src/util/writeJson.ts +28 -0
- package/src/workflow/analysis/analyzePackageChanges.ts +54 -0
- package/src/workflow/analysis/index.ts +9 -0
- package/src/workflow/analysis/isCommitForPackage.ts +23 -0
- package/src/workflow/analysis/promotePrereleaseVersions.ts +50 -0
- package/src/workflow/analysis/propagateDependencyUpdates.ts +71 -0
- package/src/workflow/analysis/sliceUnreleased.ts +36 -0
- package/src/workflow/analysis/streamPackageCommits.ts +34 -0
- package/src/workflow/changelog/buildChangelog.ts +79 -0
- package/src/workflow/changelog/buildReleaseNotes.ts +50 -0
- package/src/workflow/changelog/extractLatestVersion.ts +22 -0
- package/src/workflow/changelog/extractPlainText.ts +38 -0
- package/src/workflow/changelog/extractPreviousVersion.ts +77 -0
- package/src/workflow/changelog/extractSemVer.ts +33 -0
- package/src/workflow/changelog/index.ts +12 -0
- package/src/workflow/changelog/iterateChangelogVersions.ts +70 -0
- package/src/workflow/changelog/prependChangelog.ts +59 -0
- package/src/workflow/changelog/writeChangelog.ts +27 -0
- package/src/workflow/committing/generateReleaseCommitMessage.ts +96 -0
- package/src/workflow/committing/index.ts +5 -0
- package/src/workflow/committing/isReleaseCommit.ts +52 -0
- package/src/workflow/discovery/buildDependencyGraph.ts +43 -0
- package/src/workflow/discovery/discoverPackageTag.ts +98 -0
- package/src/workflow/discovery/index.ts +9 -0
- package/src/workflow/discovery/resolvePackageStableTag.ts +88 -0
- package/src/workflow/discovery/resolvePackageTag.ts +45 -0
- package/src/workflow/discovery/sortPackages.ts +73 -0
- package/src/workflow/discovery/verifyPackageTag.ts +43 -0
- package/src/workflow/index.ts +10 -0
- package/src/workflow/report/generateReleaseReport.ts +76 -0
- package/src/workflow/report/index.ts +5 -0
- package/src/workflow/report/printReleaseReport.ts +97 -0
- package/src/workflow/runner/index.ts +4 -0
- package/src/workflow/runner/runWorkflow.ts +120 -0
- package/src/workflow/versioning/bumpVersion.ts +67 -0
- package/src/workflow/versioning/deriveReleaseType.ts +33 -0
- package/src/workflow/versioning/determineReleaseType.ts +35 -0
- package/src/workflow/versioning/index.ts +8 -0
- package/src/workflow/versioning/isStableVersion.ts +31 -0
- package/src/workflow/versioning/pickPriorityReleaseType.ts +27 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
export * from './buildChangelog'
|
|
5
|
+
export * from './buildReleaseNotes'
|
|
6
|
+
export * from './extractLatestVersion'
|
|
7
|
+
export * from './extractPlainText'
|
|
8
|
+
export * from './extractPreviousVersion'
|
|
9
|
+
export * from './extractSemVer'
|
|
10
|
+
export * from './iterateChangelogVersions'
|
|
11
|
+
export * from './prependChangelog'
|
|
12
|
+
export * from './writeChangelog'
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import type { RootContent } from 'mdast'
|
|
5
|
+
import { fromMarkdown } from 'mdast-util-from-markdown'
|
|
6
|
+
import semver from 'semver'
|
|
7
|
+
import { extractPlainText } from './extractPlainText'
|
|
8
|
+
import { extractSemVer } from './extractSemVer'
|
|
9
|
+
|
|
10
|
+
const SENTENCE_TERMINAL_REGEX = /\p{Sentence_Terminal}$/u
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Iterate over Semantic Version strings extracted from a Markdown changelog.
|
|
14
|
+
*
|
|
15
|
+
* Parses `changelogContent` into an AST and evaluates nodes sequentially using
|
|
16
|
+
* custom extractors. Evaluates heading nodes first before checking short
|
|
17
|
+
* paragraph nodes. Stops execution after the first strategy yields valid
|
|
18
|
+
* versions.
|
|
19
|
+
*
|
|
20
|
+
* @param changelogContent Raw Markdown text of the changelog
|
|
21
|
+
* @yields Valid SemVer strings found in headings or candidate paragraphs
|
|
22
|
+
*/
|
|
23
|
+
export function* iterateChangelogVersions(
|
|
24
|
+
changelogContent: string
|
|
25
|
+
): Generator<string> {
|
|
26
|
+
const root = fromMarkdown(changelogContent)
|
|
27
|
+
|
|
28
|
+
const nodeExtractors = [
|
|
29
|
+
(node: RootContent): string | null => {
|
|
30
|
+
return node.type === 'heading' ? extractPlainText(node) : null
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
(node: RootContent): string | null => {
|
|
34
|
+
if (node.type !== 'paragraph') return null
|
|
35
|
+
|
|
36
|
+
const paragraphText = extractPlainText(node)
|
|
37
|
+
|
|
38
|
+
if (
|
|
39
|
+
!paragraphText ||
|
|
40
|
+
paragraphText.length > 48 ||
|
|
41
|
+
SENTENCE_TERMINAL_REGEX.test(paragraphText)
|
|
42
|
+
) {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return paragraphText
|
|
47
|
+
},
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
for (const extractText of nodeExtractors) {
|
|
51
|
+
let isVersionFound = false
|
|
52
|
+
|
|
53
|
+
for (const node of root.children) {
|
|
54
|
+
const extractedText = extractText(node)
|
|
55
|
+
|
|
56
|
+
const candidateVersion = extractedText
|
|
57
|
+
? semver.sort(extractSemVer(extractedText)).pop()
|
|
58
|
+
: null
|
|
59
|
+
|
|
60
|
+
if (candidateVersion && semver.valid(candidateVersion)) {
|
|
61
|
+
isVersionFound = true
|
|
62
|
+
yield candidateVersion
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (isVersionFound) {
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import { formatFile } from '#util'
|
|
5
|
+
import { fromMarkdown } from 'mdast-util-from-markdown'
|
|
6
|
+
import { toMarkdown } from 'mdast-util-to-markdown'
|
|
7
|
+
import fs from 'node:fs/promises'
|
|
8
|
+
|
|
9
|
+
const defaultChangelogTitle = 'Changelog'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Prepend changelog content to a markdown file, creating it if necessary.
|
|
13
|
+
*
|
|
14
|
+
* Reads the existing file if present, prepends the new content before the main heading,
|
|
15
|
+
* formats and writes the result back.
|
|
16
|
+
*
|
|
17
|
+
* @param filePath Path to the changelog file
|
|
18
|
+
* @param revision Changelog content to prepend
|
|
19
|
+
* @returns Promise that resolves when the changelog is written
|
|
20
|
+
*/
|
|
21
|
+
export async function prependChangelog(
|
|
22
|
+
filePath: string,
|
|
23
|
+
revision: string
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
let content = ''
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
content = await fs.readFile(filePath, 'utf-8')
|
|
29
|
+
} catch (err) {
|
|
30
|
+
/* istanbul ignore next */
|
|
31
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
32
|
+
throw err
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const root = fromMarkdown(content)
|
|
37
|
+
|
|
38
|
+
let index = root.children.findIndex(
|
|
39
|
+
(node) => node.type === 'heading' && node.depth === 1
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if (index === -1) {
|
|
43
|
+
index = 0
|
|
44
|
+
|
|
45
|
+
root.children.unshift({
|
|
46
|
+
type: 'heading',
|
|
47
|
+
depth: 1,
|
|
48
|
+
children: [{ type: 'text', value: defaultChangelogTitle }],
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
root.children.splice(index + 1, 0, ...fromMarkdown(revision).children)
|
|
53
|
+
|
|
54
|
+
content = toMarkdown(root)
|
|
55
|
+
|
|
56
|
+
content = await formatFile(filePath, content)
|
|
57
|
+
|
|
58
|
+
await fs.writeFile(filePath, content)
|
|
59
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_CHANGELOG_FILE } from '#config'
|
|
5
|
+
import type { ReleasePlan } from '#types'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { prependChangelog } from './prependChangelog'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Write changelog content to the file system based on the release plan.
|
|
11
|
+
*
|
|
12
|
+
* @param cwd Repository working directory
|
|
13
|
+
* @param plan Release plan containing package and changelog content
|
|
14
|
+
* @returns Promise that resolves when the changelog file has been written
|
|
15
|
+
*/
|
|
16
|
+
export async function writeChangelog(
|
|
17
|
+
cwd: string,
|
|
18
|
+
plan: ReleasePlan
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
if (plan.release?.changelog) {
|
|
21
|
+
const changelogFile = plan.package.changelogFile || DEFAULT_CHANGELOG_FILE
|
|
22
|
+
|
|
23
|
+
const filePath = path.join(cwd, plan.package.dir, changelogFile)
|
|
24
|
+
|
|
25
|
+
await prependChangelog(filePath, plan.release.changelog)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import { GIT_TRAILER_RELEASE_AGENT } from '#config'
|
|
5
|
+
import type { ReleasePlan } from '#types'
|
|
6
|
+
import { version } from '../../../package.json'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate a formatted release commit message based on release plans and
|
|
10
|
+
* custom trailers.
|
|
11
|
+
*
|
|
12
|
+
* Compiles package version updates and included commit logs into a standardized
|
|
13
|
+
* conventional commit message body, appending configured release agent and
|
|
14
|
+
* custom trailers. Returns null if no valid release subject can be derived.
|
|
15
|
+
*
|
|
16
|
+
* @param plans Readonly array of release plans containing package changes and commit history
|
|
17
|
+
* @param trailers Optional record of custom git trailers to append to the commit
|
|
18
|
+
* @returns The formatted release commit message string or null if the subject is empty
|
|
19
|
+
*/
|
|
20
|
+
export async function generateReleaseCommitMessage(
|
|
21
|
+
plans: readonly ReleasePlan[],
|
|
22
|
+
trailers: Readonly<Record<string, string | number | null | undefined>> = {}
|
|
23
|
+
): Promise<string | null> {
|
|
24
|
+
const standalone = plans.length === 1
|
|
25
|
+
|
|
26
|
+
trailers = {
|
|
27
|
+
[GIT_TRAILER_RELEASE_AGENT]: `ReelOut/${import.meta.env.MODE === 'test' ? '0.0.0' : /* istanbul ignore next */ version}`,
|
|
28
|
+
...trailers,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
plans = plans
|
|
32
|
+
.filter((plan) => plan.release?.tag?.name)
|
|
33
|
+
.toSorted((a, b) => {
|
|
34
|
+
if (a.package.entry) return -1
|
|
35
|
+
if (b.package.entry) return 1
|
|
36
|
+
/* istanbul ignore next */
|
|
37
|
+
return (a.package.name ?? '').localeCompare(b.package.name ?? '')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
let subject: string | null | undefined
|
|
41
|
+
|
|
42
|
+
if (plans.length === 1) {
|
|
43
|
+
subject = standalone
|
|
44
|
+
? plans[0]?.release?.version
|
|
45
|
+
: plans[0]?.release?.tag?.name
|
|
46
|
+
} else if (plans.length >= 2 && plans.length <= 3) {
|
|
47
|
+
subject = plans.map((plan) => plan.release?.tag?.name).join(', ')
|
|
48
|
+
} else if (plans.length > 3) {
|
|
49
|
+
subject = `${plans.length} packages (${plans
|
|
50
|
+
.slice(0, 2)
|
|
51
|
+
.map((plan) => plan.release?.tag?.name)
|
|
52
|
+
.join(', ')}, +${plans.length - 2} more)`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!subject) {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const packages = plans.map((plan) => {
|
|
60
|
+
return `- ${plan.package.name || plan.package.dir}: ${plan.package.version} → ${plan.release?.version}`
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const commits = Array.from(
|
|
64
|
+
new Map(
|
|
65
|
+
plans
|
|
66
|
+
.flatMap((plan) => plan.history)
|
|
67
|
+
.map((commit) => [commit.hash, commit])
|
|
68
|
+
).values()
|
|
69
|
+
)
|
|
70
|
+
.toSorted((a, b) => {
|
|
71
|
+
return (
|
|
72
|
+
new Date(a.committerDate).valueOf() -
|
|
73
|
+
new Date(b.committerDate).valueOf()
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
.map(({ header }) => `- ${header}`)
|
|
77
|
+
|
|
78
|
+
return [
|
|
79
|
+
`chore: release ${subject}`,
|
|
80
|
+
|
|
81
|
+
packages.length >= 2
|
|
82
|
+
? ['## Version Updates', ...packages].join('\n')
|
|
83
|
+
: undefined,
|
|
84
|
+
|
|
85
|
+
commits.length > 0
|
|
86
|
+
? ['## Changes Included', ...commits].join('\n')
|
|
87
|
+
: undefined,
|
|
88
|
+
|
|
89
|
+
Object.entries(trailers)
|
|
90
|
+
.map(([key, value]) => (key && value != null ? `${key}: ${value}` : ''))
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.join('\n'),
|
|
93
|
+
]
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
.join('\n\n')
|
|
96
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import { GIT_TRAILER_RELEASE_AGENT } from '#config'
|
|
5
|
+
import { getParsedCommit } from '#git'
|
|
6
|
+
import type { Commit, CommitTrailer } from '#types'
|
|
7
|
+
import { execGit } from '#util'
|
|
8
|
+
|
|
9
|
+
export type ReleaseMarker = string | ((trailer: CommitTrailer) => boolean)
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Determine whether a commit represents a release or matches a release marker.
|
|
13
|
+
*
|
|
14
|
+
* Resolves string commit identifiers to parsed commit objects, checks if any
|
|
15
|
+
* associated trailer matches the marker predicate, and falls back to verifying
|
|
16
|
+
* exact tag matches for the `commit` hash.
|
|
17
|
+
*
|
|
18
|
+
* @param cwd The working directory path for Git commands
|
|
19
|
+
* @param commit The commit object, hash string, or null/undefined
|
|
20
|
+
* @param marker The trailer key string or a predicate function to evaluate
|
|
21
|
+
* @returns True if the commit is a release commit, otherwise false
|
|
22
|
+
*/
|
|
23
|
+
export async function isReleaseCommit(
|
|
24
|
+
cwd: string,
|
|
25
|
+
commit: Commit | string | null | undefined,
|
|
26
|
+
marker: ReleaseMarker = GIT_TRAILER_RELEASE_AGENT
|
|
27
|
+
): Promise<boolean> {
|
|
28
|
+
if (typeof commit === 'string') {
|
|
29
|
+
commit = await getParsedCommit(
|
|
30
|
+
cwd,
|
|
31
|
+
commit,
|
|
32
|
+
typeof marker === 'string' ? [marker] : []
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!commit) {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const predicate =
|
|
41
|
+
typeof marker === 'string'
|
|
42
|
+
? (trailer: CommitTrailer) => trailer.key === marker && !!trailer.value
|
|
43
|
+
: marker
|
|
44
|
+
|
|
45
|
+
if (commit.trailers.some(predicate)) {
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return execGit(cwd, ['describe', '--tags', '--exact-match', commit.hash])
|
|
50
|
+
.then(() => true)
|
|
51
|
+
.catch(() => false)
|
|
52
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import type { ReleasePlan } from '#types'
|
|
5
|
+
|
|
6
|
+
export type DependencyGraph = Map<string, Set<ReleasePlan>>
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build a dependency graph mapping package names to dependent release plans.
|
|
10
|
+
*
|
|
11
|
+
* Each key is a package name and the value is a set of `ReleasePlan`s that
|
|
12
|
+
* list that package as a dependency.
|
|
13
|
+
*
|
|
14
|
+
* @param plans Array of release plans to analyze
|
|
15
|
+
* @returns Dependency graph mapping package name -> set of dependent plans
|
|
16
|
+
*/
|
|
17
|
+
export function buildDependencyGraph(
|
|
18
|
+
plans: readonly ReleasePlan[]
|
|
19
|
+
): DependencyGraph {
|
|
20
|
+
const graph = new Map<string, Set<ReleasePlan>>()
|
|
21
|
+
|
|
22
|
+
const packageNames = new Set(
|
|
23
|
+
plans
|
|
24
|
+
.map((plan) => plan.package.name)
|
|
25
|
+
.filter((name): name is string => !!name)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
for (const plan of plans) {
|
|
29
|
+
for (const depName of plan.package.dependencies) {
|
|
30
|
+
let set = graph.get(depName)
|
|
31
|
+
|
|
32
|
+
if (!set && packageNames.has(depName)) {
|
|
33
|
+
graph.set(depName, (set = new Set()))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (set) {
|
|
37
|
+
set.add(plan)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return graph
|
|
43
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_CHANGELOG_FILE } from '#config'
|
|
5
|
+
import {
|
|
6
|
+
generateGitTagName,
|
|
7
|
+
getGitTagMessage,
|
|
8
|
+
readGitFile,
|
|
9
|
+
resolveGitRevision,
|
|
10
|
+
} from '#git'
|
|
11
|
+
import type { Package, Tag } from '#types'
|
|
12
|
+
import { readFile } from 'node:fs/promises'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { extractLatestVersion } from '../changelog/extractLatestVersion'
|
|
15
|
+
import { verifyPackageTag } from './verifyPackageTag'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Discover and validate the Git tag for a package.
|
|
19
|
+
*
|
|
20
|
+
* Returns the existing tag if already present on the `pkg` object. Resolves
|
|
21
|
+
* the local changelog file path, extracts the current version, and generates
|
|
22
|
+
* the expected Git tag name. Verifies the tag against the repository and
|
|
23
|
+
* ensures its historical changelog version matches the current local version.
|
|
24
|
+
*
|
|
25
|
+
* @param cwd The current working directory
|
|
26
|
+
* @param pkg The package configuration object
|
|
27
|
+
* @returns The validated `Tag` object or null if validation fails
|
|
28
|
+
*/
|
|
29
|
+
export async function discoverPackageTag(
|
|
30
|
+
cwd: string,
|
|
31
|
+
pkg: Package
|
|
32
|
+
): Promise<Tag | null> {
|
|
33
|
+
if (pkg.tag) {
|
|
34
|
+
return pkg.tag
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const changelogFile = pkg.changelogFile || DEFAULT_CHANGELOG_FILE
|
|
38
|
+
|
|
39
|
+
const changelogFilePath = path.resolve(cwd, pkg.dir, changelogFile)
|
|
40
|
+
|
|
41
|
+
let localChangelogContent: string | null | undefined
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
localChangelogContent = await readFile(changelogFilePath, 'utf-8')
|
|
45
|
+
} catch {}
|
|
46
|
+
|
|
47
|
+
if (!localChangelogContent) {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const currentVersion = extractLatestVersion(localChangelogContent)
|
|
52
|
+
|
|
53
|
+
if (!currentVersion) {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let expectedTagName: string | null | undefined
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
expectedTagName = generateGitTagName(pkg.entry, pkg.name, currentVersion)
|
|
61
|
+
} catch {}
|
|
62
|
+
|
|
63
|
+
if (!expectedTagName) {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!(await verifyPackageTag(cwd, pkg, expectedTagName))) {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const tagRef = `refs/tags/${expectedTagName}`
|
|
72
|
+
|
|
73
|
+
const historicalChangelogContent = await readGitFile(
|
|
74
|
+
cwd,
|
|
75
|
+
await resolveGitRevision(cwd, tagRef),
|
|
76
|
+
path.relative(cwd, changelogFilePath)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if (!historicalChangelogContent) {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const historicalChangelogVersion = extractLatestVersion(
|
|
84
|
+
historicalChangelogContent
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if (
|
|
88
|
+
!historicalChangelogVersion ||
|
|
89
|
+
historicalChangelogVersion !== currentVersion
|
|
90
|
+
) {
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
name: expectedTagName,
|
|
96
|
+
message: await getGitTagMessage(cwd, expectedTagName),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
export * from './buildDependencyGraph'
|
|
5
|
+
export * from './discoverPackageTag'
|
|
6
|
+
export * from './resolvePackageStableTag'
|
|
7
|
+
export * from './resolvePackageTag'
|
|
8
|
+
export * from './sortPackages'
|
|
9
|
+
export * from './verifyPackageTag'
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_CHANGELOG_FILE } from '#config'
|
|
5
|
+
import { generateGitTagName, getGitTagMessage } from '#git'
|
|
6
|
+
import type { Package, Tag } from '#types'
|
|
7
|
+
import assert from 'node:assert'
|
|
8
|
+
import { readFile } from 'node:fs/promises'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import semver from 'semver'
|
|
11
|
+
import { iterateChangelogVersions } from '../changelog/iterateChangelogVersions'
|
|
12
|
+
import { verifyPackageTag } from './verifyPackageTag'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the latest stable Git tag for a package.
|
|
16
|
+
*
|
|
17
|
+
* Reads changelog release versions for `pkg` and verifies strict descending
|
|
18
|
+
* semver order. Ignores prerelease versions unless transitioning across major
|
|
19
|
+
* or prerelease boundaries, and verifies that the matching Git tag exists.
|
|
20
|
+
*
|
|
21
|
+
* @param cwd Absolute path to the root working directory
|
|
22
|
+
* @param pkg Package configuration object
|
|
23
|
+
* @returns Verified tag metadata object or `null` if no stable tag is resolved
|
|
24
|
+
* @throws If a version is invalid, versions are not in strict descending
|
|
25
|
+
* order, or the corresponding Git tag is missing
|
|
26
|
+
*/
|
|
27
|
+
export async function resolvePackageStableTag(
|
|
28
|
+
cwd: string,
|
|
29
|
+
pkg: Package
|
|
30
|
+
): Promise<Tag | null> {
|
|
31
|
+
const changelogFile = pkg.changelogFile || DEFAULT_CHANGELOG_FILE
|
|
32
|
+
|
|
33
|
+
const changelogFilePath = path.resolve(cwd, pkg.dir, changelogFile)
|
|
34
|
+
|
|
35
|
+
let changelogContent: string
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
changelogContent = await readFile(changelogFilePath, 'utf-8')
|
|
39
|
+
} catch (error: unknown) {
|
|
40
|
+
/* istanbul ignore else */
|
|
41
|
+
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* istanbul ignore next */
|
|
46
|
+
throw error
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let precedingVersion: semver.SemVer | null = null
|
|
50
|
+
|
|
51
|
+
for (const version of iterateChangelogVersions(changelogContent)) {
|
|
52
|
+
const currentVersion = semver.parse(version)
|
|
53
|
+
|
|
54
|
+
assert(
|
|
55
|
+
currentVersion,
|
|
56
|
+
`Version "${version}" is not a valid semantic version.`
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if (precedingVersion && semver.gte(currentVersion, precedingVersion)) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Version "${version}" must be strictly lower than preceding version "${precedingVersion.raw}".`
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
!semver.prerelease(currentVersion) &&
|
|
67
|
+
(currentVersion.major >= 1 ||
|
|
68
|
+
(precedingVersion && semver.prerelease(precedingVersion)))
|
|
69
|
+
) {
|
|
70
|
+
const tagName = generateGitTagName(pkg.entry, pkg.name, version)
|
|
71
|
+
|
|
72
|
+
if (!(await verifyPackageTag(cwd, pkg, tagName))) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Git tag "${tagName}" is not associated with package "${pkg.name ?? ''}" in directory "${pkg.dir}".`
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
name: tagName,
|
|
80
|
+
message: await getGitTagMessage(cwd, tagName),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
precedingVersion = currentVersion
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import { generateGitTagName, getGitTagMessage } from '#git'
|
|
5
|
+
import type { Package, Tag } from '#types'
|
|
6
|
+
import { verifyPackageTag } from './verifyPackageTag'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the release tag for a single package version.
|
|
10
|
+
*
|
|
11
|
+
* Returns the cached `pkg.tag` immediately if it already exists. Generates an
|
|
12
|
+
* expected tag name from the package metadata and verifies its integrity using
|
|
13
|
+
* `verifyPackageTag`.
|
|
14
|
+
*
|
|
15
|
+
* @param cwd Repository working directory
|
|
16
|
+
* @param pkg Package descriptor containing version and location context
|
|
17
|
+
* @returns A promise resolving to the tag metadata or null when not found
|
|
18
|
+
*/
|
|
19
|
+
export async function resolvePackageTag(
|
|
20
|
+
cwd: string,
|
|
21
|
+
pkg: Package
|
|
22
|
+
): Promise<Tag | null> {
|
|
23
|
+
if (pkg.tag) {
|
|
24
|
+
return pkg.tag
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let expectedTagName: string | null | undefined
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
expectedTagName = generateGitTagName(pkg.entry, pkg.name, pkg.version)
|
|
31
|
+
} catch {}
|
|
32
|
+
|
|
33
|
+
if (!expectedTagName) {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (await verifyPackageTag(cwd, pkg, expectedTagName)) {
|
|
38
|
+
return {
|
|
39
|
+
name: expectedTagName,
|
|
40
|
+
message: await getGitTagMessage(cwd, expectedTagName),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import type { Package } from '#types'
|
|
5
|
+
|
|
6
|
+
interface PackageNode {
|
|
7
|
+
package: Package
|
|
8
|
+
level: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Sort workspace packages topologically based on their dependency graphs.
|
|
13
|
+
*
|
|
14
|
+
* Deepest dependents are placed first in the resulting array, ensuring that
|
|
15
|
+
* a package always precedes its own dependencies. Ties and circular dependencies
|
|
16
|
+
* are resolved deterministically using an alphabetical sort on the package directory (`dir`).
|
|
17
|
+
*
|
|
18
|
+
* @param packages The workspace packages to sort.
|
|
19
|
+
* @returns A new array of packages sorted by dependency depth and directory path.
|
|
20
|
+
*/
|
|
21
|
+
export function sortPackages(packages: readonly Package[]): Package[] {
|
|
22
|
+
const packageNodes: PackageNode[] = []
|
|
23
|
+
const packageMap = new Map<string, PackageNode>()
|
|
24
|
+
|
|
25
|
+
for (const pkg of packages.toSorted((a, b) => a.dir.localeCompare(b.dir))) {
|
|
26
|
+
const node: PackageNode = {
|
|
27
|
+
package: pkg,
|
|
28
|
+
level: -1,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
packageNodes.push(node)
|
|
32
|
+
|
|
33
|
+
if (pkg.name) {
|
|
34
|
+
packageMap.set(pkg.name, node)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const node of packageNodes) {
|
|
39
|
+
visit(node)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function visit(
|
|
43
|
+
node: PackageNode,
|
|
44
|
+
level = 0,
|
|
45
|
+
visited = new Set<PackageNode>()
|
|
46
|
+
): void {
|
|
47
|
+
if (node.level >= level) {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (visited.has(node)) {
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
visited.add(node)
|
|
56
|
+
|
|
57
|
+
for (const depName of node.package.dependencies) {
|
|
58
|
+
const depNode = packageMap.get(depName)
|
|
59
|
+
|
|
60
|
+
if (depNode && depNode !== node) {
|
|
61
|
+
visit(depNode, level + 1, visited)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
node.level = level
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const sorted = packageNodes
|
|
69
|
+
.toSorted((a, b) => b.level - a.level)
|
|
70
|
+
.map((node) => node.package)
|
|
71
|
+
|
|
72
|
+
return sorted
|
|
73
|
+
}
|