@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.
Files changed (125) hide show
  1. package/dist/analyzer.js +38 -0
  2. package/dist/analyzer.js.map +1 -0
  3. package/dist/builder.js +52 -0
  4. package/dist/builder.js.map +1 -0
  5. package/dist/changelog.js +58 -0
  6. package/dist/changelog.js.map +1 -0
  7. package/dist/formatError-BXNdyRfu.js +23 -0
  8. package/dist/formatError-BXNdyRfu.js.map +1 -0
  9. package/dist/index.js +175 -1
  10. package/dist/index.js.map +1 -0
  11. package/dist/types-la_KkjCS.js +1 -0
  12. package/dist/types.js +2 -0
  13. package/dist/workflow-BoVmbvH6.js +2342 -0
  14. package/dist/workflow-BoVmbvH6.js.map +1 -0
  15. package/package.json +27 -3
  16. package/src/analyzer.ts +63 -0
  17. package/src/builder.ts +104 -0
  18. package/src/cache/TTLCache.ts +100 -0
  19. package/src/cache/index.ts +4 -0
  20. package/src/changelog.ts +97 -0
  21. package/src/config/const.ts +97 -0
  22. package/src/config/index.ts +11 -0
  23. package/src/config/isSemVerGitTag.ts +25 -0
  24. package/src/config/normalizeCommitFilter.ts +37 -0
  25. package/src/config/normalizeReleaseRule.ts +42 -0
  26. package/src/config/normalizeWorkspace.ts +34 -0
  27. package/src/config/resolvePlugin.ts +56 -0
  28. package/src/config/resolveReleaseTarget.ts +71 -0
  29. package/src/config/sanitizePrereleaseLabel.ts +18 -0
  30. package/src/git/branch/getCurrentGitBranch.ts +18 -0
  31. package/src/git/branch/index.ts +4 -0
  32. package/src/git/commit/createGitCommit.ts +29 -0
  33. package/src/git/commit/extractMentions.ts +34 -0
  34. package/src/git/commit/getCurrentGitCommit.ts +14 -0
  35. package/src/git/commit/getParsedCommit.ts +53 -0
  36. package/src/git/commit/index.ts +9 -0
  37. package/src/git/commit/parseCommit.ts +99 -0
  38. package/src/git/commit/parseGitPrettyFormat.ts +76 -0
  39. package/src/git/history/countGitHistory.ts +28 -0
  40. package/src/git/history/expandGitHistory.ts +108 -0
  41. package/src/git/history/index.ts +9 -0
  42. package/src/git/history/mergeGitHistory.ts +76 -0
  43. package/src/git/history/restoreFullGitHistory.ts +19 -0
  44. package/src/git/history/showGitHistory.ts +80 -0
  45. package/src/git/history/showParsedGitHistory.ts +30 -0
  46. package/src/git/index.ts +9 -0
  47. package/src/git/repository/ensureGitUserConfig.ts +31 -0
  48. package/src/git/repository/getGitConfigValue.ts +27 -0
  49. package/src/git/repository/getGitRemote.ts +20 -0
  50. package/src/git/repository/index.ts +10 -0
  51. package/src/git/repository/isGitShallowRepository.ts +21 -0
  52. package/src/git/repository/pushGitChanges.ts +25 -0
  53. package/src/git/repository/resolveGitRepository.ts +40 -0
  54. package/src/git/repository/withShallowLock.ts +62 -0
  55. package/src/git/revision/detectGitRelationship.ts +43 -0
  56. package/src/git/revision/index.ts +7 -0
  57. package/src/git/revision/isGitAncestor.ts +33 -0
  58. package/src/git/revision/readGitFile.ts +29 -0
  59. package/src/git/revision/resolveGitRevision.ts +23 -0
  60. package/src/git/tag/createGitTag.ts +25 -0
  61. package/src/git/tag/deleteGitTag.ts +17 -0
  62. package/src/git/tag/ensureGitTag.ts +63 -0
  63. package/src/git/tag/generateGitTagName.ts +43 -0
  64. package/src/git/tag/getCurrentGitTag.ts +23 -0
  65. package/src/git/tag/getGitTagMessage.ts +35 -0
  66. package/src/git/tag/getLatestGitTag.ts +22 -0
  67. package/src/git/tag/index.ts +13 -0
  68. package/src/git/tag/isGitTagConnected.ts +33 -0
  69. package/src/git/tag/isGitTagPointed.ts +38 -0
  70. package/src/git/tag/isGitTagPresent.ts +17 -0
  71. package/src/index.ts +8 -2
  72. package/src/types.ts +217 -0
  73. package/src/util/buildTrailerPattern.ts +20 -0
  74. package/src/util/compact.ts +14 -0
  75. package/src/util/execCommand.ts +130 -0
  76. package/src/util/execGit.ts +18 -0
  77. package/src/util/formatError.ts +21 -0
  78. package/src/util/formatFile.ts +41 -0
  79. package/src/util/index.ts +16 -0
  80. package/src/util/interpolate.ts +141 -0
  81. package/src/util/isCI.ts +23 -0
  82. package/src/util/isDefined.ts +12 -0
  83. package/src/util/isMatch.ts +43 -0
  84. package/src/util/readJson.ts +47 -0
  85. package/src/util/unique.ts +26 -0
  86. package/src/util/writeJson.ts +28 -0
  87. package/src/workflow/analysis/analyzePackageChanges.ts +54 -0
  88. package/src/workflow/analysis/index.ts +9 -0
  89. package/src/workflow/analysis/isCommitForPackage.ts +23 -0
  90. package/src/workflow/analysis/promotePrereleaseVersions.ts +50 -0
  91. package/src/workflow/analysis/propagateDependencyUpdates.ts +71 -0
  92. package/src/workflow/analysis/sliceUnreleased.ts +36 -0
  93. package/src/workflow/analysis/streamPackageCommits.ts +34 -0
  94. package/src/workflow/changelog/buildChangelog.ts +79 -0
  95. package/src/workflow/changelog/buildReleaseNotes.ts +50 -0
  96. package/src/workflow/changelog/extractLatestVersion.ts +22 -0
  97. package/src/workflow/changelog/extractPlainText.ts +38 -0
  98. package/src/workflow/changelog/extractPreviousVersion.ts +77 -0
  99. package/src/workflow/changelog/extractSemVer.ts +33 -0
  100. package/src/workflow/changelog/index.ts +12 -0
  101. package/src/workflow/changelog/iterateChangelogVersions.ts +70 -0
  102. package/src/workflow/changelog/prependChangelog.ts +59 -0
  103. package/src/workflow/changelog/writeChangelog.ts +27 -0
  104. package/src/workflow/committing/generateReleaseCommitMessage.ts +96 -0
  105. package/src/workflow/committing/index.ts +5 -0
  106. package/src/workflow/committing/isReleaseCommit.ts +52 -0
  107. package/src/workflow/discovery/buildDependencyGraph.ts +43 -0
  108. package/src/workflow/discovery/discoverPackageTag.ts +98 -0
  109. package/src/workflow/discovery/index.ts +9 -0
  110. package/src/workflow/discovery/resolvePackageStableTag.ts +88 -0
  111. package/src/workflow/discovery/resolvePackageTag.ts +45 -0
  112. package/src/workflow/discovery/sortPackages.ts +73 -0
  113. package/src/workflow/discovery/verifyPackageTag.ts +43 -0
  114. package/src/workflow/index.ts +10 -0
  115. package/src/workflow/report/generateReleaseReport.ts +76 -0
  116. package/src/workflow/report/index.ts +5 -0
  117. package/src/workflow/report/printReleaseReport.ts +97 -0
  118. package/src/workflow/runner/index.ts +4 -0
  119. package/src/workflow/runner/runWorkflow.ts +120 -0
  120. package/src/workflow/versioning/bumpVersion.ts +67 -0
  121. package/src/workflow/versioning/deriveReleaseType.ts +33 -0
  122. package/src/workflow/versioning/determineReleaseType.ts +35 -0
  123. package/src/workflow/versioning/index.ts +8 -0
  124. package/src/workflow/versioning/isStableVersion.ts +31 -0
  125. 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,5 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ export * from './generateReleaseCommitMessage'
5
+ export * from './isReleaseCommit'
@@ -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
+ }