From fd28cde9bc90fa3daa0197f46beda543556dfa7a Mon Sep 17 00:00:00 2001 From: Joost van der Waal Date: Fri, 5 Jun 2026 21:31:40 +0200 Subject: [PATCH 1/4] feat: pack order --- .../src/bicep-project.ts | 5 + .../packages/gitversion/src/commands/pack.ts | 125 +++++++++--------- .../gitversion/src/core/topo-sort._test_.ts | 67 ++++++++++ .../packages/gitversion/src/core/topo-sort.ts | 40 ++++++ .../gitversion/src/core/workspace-utils.ts | 2 + .../src/plugins/embedded/node/node-project.ts | 13 ++ 6 files changed, 190 insertions(+), 62 deletions(-) create mode 100644 workspaces/packages/gitversion/src/core/topo-sort._test_.ts create mode 100644 workspaces/packages/gitversion/src/core/topo-sort.ts diff --git a/workspaces/packages/gitversion-plugin-bicep/src/bicep-project.ts b/workspaces/packages/gitversion-plugin-bicep/src/bicep-project.ts index b0f240d..c57ff39 100644 --- a/workspaces/packages/gitversion-plugin-bicep/src/bicep-project.ts +++ b/workspaces/packages/gitversion-plugin-bicep/src/bicep-project.ts @@ -110,6 +110,11 @@ export class BicepWorkspace implements IWorkspace { return this.config.options.versionTagPrefix; } } + + get workspaceDependencies(): string[] { + return this.manifest.dependencies ?? []; + } + constructor(project: BicepProjectImpl, relativeCwd: string, manifestContent: BicepManifestContent) { this.manifestContent = manifestContent; diff --git a/workspaces/packages/gitversion/src/commands/pack.ts b/workspaces/packages/gitversion/src/commands/pack.ts index ba531fa..18d664e 100644 --- a/workspaces/packages/gitversion/src/commands/pack.ts +++ b/workspaces/packages/gitversion/src/commands/pack.ts @@ -10,6 +10,7 @@ import { Application, IApplication } from '../core/application'; import { Bump, BumpManifest } from '../core/bump-manifest'; import { formatFileSize, formatPackageName } from '../core/format-utils'; import { PackArtifact } from '../core/pack-artifact'; +import { topoSort } from '../core/topo-sort'; import { IWorkspace } from '../core/workspace-utils'; import { GitVersionCommand } from './context'; @@ -77,93 +78,93 @@ export class PackCommand extends GitVersionCommand { const packFolder = join(configuration.stagingFolder, 'pack'); await mkdir(packFolder, { recursive: true }); - const queue = new Queue({ - concurrent: this.maxConcurrency ?? cpus().length, - start: false, - }); - - bumpManifest.bumps.forEach(bump => { - if (bump.packageRelativeCwd === '.' && project.childWorkspaces.length > 0) { - // Root project workspace is already added directly via packManifest.add(projectBump) above. - // Only update its version/changelog on disk; do not pack it again. - const rootWorkspace = project.workspaces.find(w => w.relativeCwd === '.'); - if (rootWorkspace) { - queue.enqueue(async () => { - try { - await rootWorkspace.updateVersion(bump.version); - await rootWorkspace.updateChangelog(bump.changeLog); - } catch (error) { - hasErrors = true; - throw error; - } - }); - } - return; - } - - queue.enqueue(async () => { + // Phase 1: write all versions to disk in parallel so every package.json is + // up-to-date before any pack subprocess reads them. + await Promise.all(bumpManifest.bumps.map(async bump => { + const workspace = project.workspaces.find(w => w.relativeCwd === bump.packageRelativeCwd); + if (workspace) { try { - const workspace = project.workspaces.find(w => w.relativeCwd === bump.packageRelativeCwd); - if (workspace) { - await workspace.updateVersion(bump.version); - await workspace.updateChangelog(bump.changeLog); - await this.execPackCommand(application, workspace, bump, packManifest, false); - } + await workspace.updateVersion(bump.version); + await workspace.updateChangelog(bump.changeLog); } catch (error) { hasErrors = true; throw error; } - }); - }); - - while (queue.shouldRun) { - await queue.dequeue(); - } - } - - // --- Republish workspaces (workspaces not covered by the bump manifest) --- - if (this.republish) { - const bumpedCwds = new Set(bumpManifest?.bumps.map(b => b.packageRelativeCwd) ?? []); - const republishWorkspaces = project.workspaces.filter(w => !w.private && !bumpedCwds.has(w.relativeCwd)); + } + })); - if (republishWorkspaces.length > 0) { - hasSomethingToPack = true; - - const packFolder = join(configuration.stagingFolder, 'pack'); - await mkdir(packFolder, { recursive: true }); + // Phase 2: pack in topological dependency order, parallel within each level. + // The root workspace (when it has children) is registered in packManifest above but not packed. + const workspacesToPack = bumpManifest.bumps + .filter(b => !(b.packageRelativeCwd === '.' && project.childWorkspaces.length > 0)) + .map(b => project.workspaces.find(w => w.relativeCwd === b.packageRelativeCwd)) + .filter((w): w is IWorkspace => !!w); + for (const level of topoSort(workspacesToPack)) { const queue = new Queue({ concurrent: this.maxConcurrency ?? cpus().length, start: false, }); - - republishWorkspaces.forEach(workspace => { + level.forEach(workspace => { + const bump = bumpManifest.bumps.find(b => b.packageRelativeCwd === workspace.relativeCwd)!; queue.enqueue(async () => { try { - const syntheticBump: Bump = { - packageRelativeCwd: workspace.relativeCwd, - packageName: workspace.packageName, - version: workspace.version, - previousVersion: workspace.version, - tag: workspace.tagPrefix + workspace.version, - private: false, - commits: [], - changeLog: { version: workspace.version, headerLine: '', body: '' }, - }; - await this.execPackCommand(application, workspace, syntheticBump, packManifest, true); + await this.execPackCommand(application, workspace, bump, packManifest, false); } catch (error) { hasErrors = true; throw error; } }); }); - while (queue.shouldRun) { await queue.dequeue(); } } } + // --- Republish workspaces (workspaces not covered by the bump manifest) --- + if (this.republish) { + const bumpedCwds = new Set(bumpManifest?.bumps.map(b => b.packageRelativeCwd) ?? []); + const republishWorkspaces = project.workspaces.filter(w => !w.private && !bumpedCwds.has(w.relativeCwd)); + + if (republishWorkspaces.length > 0) { + hasSomethingToPack = true; + + const packFolder = join(configuration.stagingFolder, 'pack'); + await mkdir(packFolder, { recursive: true }); + + for (const level of topoSort(republishWorkspaces)) { + const queue = new Queue({ + concurrent: this.maxConcurrency ?? cpus().length, + start: false, + }); + level.forEach(workspace => { + queue.enqueue(async () => { + try { + const syntheticBump: Bump = { + packageRelativeCwd: workspace.relativeCwd, + packageName: workspace.packageName, + version: workspace.version, + previousVersion: workspace.version, + tag: workspace.tagPrefix + workspace.version, + private: false, + commits: [], + changeLog: { version: workspace.version, headerLine: '', body: '' }, + }; + await this.execPackCommand(application, workspace, syntheticBump, packManifest, true); + } catch (error) { + hasErrors = true; + throw error; + } + }); + }); + while (queue.shouldRun) { + await queue.dequeue(); + } + } + } + } + if (!hasSomethingToPack) { logger.reportWarning('Nothing to pack'); } diff --git a/workspaces/packages/gitversion/src/core/topo-sort._test_.ts b/workspaces/packages/gitversion/src/core/topo-sort._test_.ts new file mode 100644 index 0000000..aaed159 --- /dev/null +++ b/workspaces/packages/gitversion/src/core/topo-sort._test_.ts @@ -0,0 +1,67 @@ +import { IWorkspace } from './workspace-utils'; +import { topoSort } from './topo-sort'; + +function makeWorkspace(packageName: string, deps: string[] = []): IWorkspace { + return { + packageName, + workspaceDependencies: deps, + } as unknown as IWorkspace; +} + +describe('topoSort', () => { + it('returns empty array for empty input', () => { + expect(topoSort([])).toEqual([]); + }); + + it('returns single workspace in one level', () => { + const a = makeWorkspace('a'); + expect(topoSort([a])).toEqual([[a]]); + }); + + it('linear chain: dependency before dependent', () => { + const a = makeWorkspace('a'); + const b = makeWorkspace('b', ['a']); + const c = makeWorkspace('c', ['b']); + const levels = topoSort([c, b, a]); + expect(levels).toHaveLength(3); + expect(levels[0]).toEqual([a]); + expect(levels[1]).toEqual([b]); + expect(levels[2]).toEqual([c]); + }); + + it('diamond: shared dep in level 0, both consumers in level 1, final in level 2', () => { + const shared = makeWorkspace('shared'); + const left = makeWorkspace('left', ['shared']); + const right = makeWorkspace('right', ['shared']); + const top = makeWorkspace('top', ['left', 'right']); + const levels = topoSort([top, right, left, shared]); + expect(levels[0]).toEqual([shared]); + expect(levels[1]).toHaveLength(2); + expect(levels[1]).toEqual(expect.arrayContaining([left, right])); + expect(levels[2]).toEqual([top]); + }); + + it('disconnected graph: independent workspaces in the same level', () => { + const a = makeWorkspace('a'); + const b = makeWorkspace('b'); + const levels = topoSort([a, b]); + expect(levels).toHaveLength(1); + expect(levels[0]).toHaveLength(2); + expect(levels[0]).toEqual(expect.arrayContaining([a, b])); + }); + + it('external deps (not in workspace set) are ignored', () => { + const a = makeWorkspace('a', ['lodash', 'external-pkg']); + const levels = topoSort([a]); + expect(levels).toEqual([[a]]); + }); + + it('cycle fallback: all workspaces are still returned', () => { + const a = makeWorkspace('a', ['b']); + const b = makeWorkspace('b', ['a']); + const levels = topoSort([a, b]); + const allWorkspaces = levels.flat(); + expect(allWorkspaces).toHaveLength(2); + expect(allWorkspaces).toEqual(expect.arrayContaining([a, b])); + }); +}); diff --git a/workspaces/packages/gitversion/src/core/topo-sort.ts b/workspaces/packages/gitversion/src/core/topo-sort.ts new file mode 100644 index 0000000..8460705 --- /dev/null +++ b/workspaces/packages/gitversion/src/core/topo-sort.ts @@ -0,0 +1,40 @@ +import { IWorkspace } from './workspace-utils'; + +export function topoSort(workspaces: IWorkspace[]): IWorkspace[][] { + const nameToWs = new Map(workspaces.map(w => [w.packageName, w])); + const inDegree = new Map(workspaces.map(w => [w.packageName, 0])); + const dependents = new Map(workspaces.map(w => [w.packageName, []])); + + for (const w of workspaces) { + for (const dep of w.workspaceDependencies) { + if (nameToWs.has(dep)) { + dependents.get(dep)!.push(w.packageName); + inDegree.set(w.packageName, inDegree.get(w.packageName)! + 1); + } + } + } + + const levels: IWorkspace[][] = []; + let current = workspaces.filter(w => inDegree.get(w.packageName) === 0); + const processed = new Set(); + + while (current.length > 0) { + levels.push(current); + const next: IWorkspace[] = []; + for (const w of current) { + processed.add(w.packageName); + for (const dependent of dependents.get(w.packageName) ?? []) { + const d = inDegree.get(dependent)! - 1; + inDegree.set(dependent, d); + if (d === 0) next.push(nameToWs.get(dependent)!); + } + } + current = next; + } + + // Cycle fallback: append any unresolved workspaces as a final level + const unprocessed = workspaces.filter(w => !processed.has(w.packageName)); + if (unprocessed.length > 0) levels.push(unprocessed); + + return levels; +} diff --git a/workspaces/packages/gitversion/src/core/workspace-utils.ts b/workspaces/packages/gitversion/src/core/workspace-utils.ts index a6bb30d..13bb13c 100644 --- a/workspaces/packages/gitversion/src/core/workspace-utils.ts +++ b/workspaces/packages/gitversion/src/core/workspace-utils.ts @@ -28,6 +28,8 @@ export interface IWorkspace { readonly tagPrefix: string; + readonly workspaceDependencies: string[]; + updateChangelog(entry: ChangelogEntry): Promise; updateVersion(version: string): Promise; diff --git a/workspaces/packages/gitversion/src/plugins/embedded/node/node-project.ts b/workspaces/packages/gitversion/src/plugins/embedded/node/node-project.ts index d3d2bba..a552ede 100644 --- a/workspaces/packages/gitversion/src/plugins/embedded/node/node-project.ts +++ b/workspaces/packages/gitversion/src/plugins/embedded/node/node-project.ts @@ -21,6 +21,9 @@ export const isNodeManifest = t.isPartial({ name: t.isString(), private: t.isOptional(t.isBoolean()), workspaces: t.isOptional(t.isArray(t.isString())), + dependencies: t.isOptional(t.isRecord(t.isString())), + devDependencies: t.isOptional(t.isRecord(t.isString())), + peerDependencies: t.isOptional(t.isRecord(t.isString())), }); export type NodeManifest = t.InferType; @@ -100,6 +103,16 @@ export class NodeWorkspace implements IWorkspace { } } + get workspaceDependencies(): string[] { + const allWorkspaceNames = new Set(this.project.workspaces.map(w => w.packageName)); + const allDeps = { + ...this.manifest.dependencies, + ...this.manifest.devDependencies, + ...this.manifest.peerDependencies, + }; + return Object.keys(allDeps).filter(dep => allWorkspaceNames.has(dep)); + } + constructor(project: NodeProject, relativeCwd: string, manifestContent: NodeManifestContent) { this.manifestContent = manifestContent; From ec78bcc922a401c871c2a71eec601aa399f50446 Mon Sep 17 00:00:00 2001 From: Joost van der Waal Date: Fri, 5 Jun 2026 21:40:31 +0200 Subject: [PATCH 2/4] linting --- workspaces/packages/gitversion/src/core/topo-sort._test_.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspaces/packages/gitversion/src/core/topo-sort._test_.ts b/workspaces/packages/gitversion/src/core/topo-sort._test_.ts index aaed159..b4b7ad6 100644 --- a/workspaces/packages/gitversion/src/core/topo-sort._test_.ts +++ b/workspaces/packages/gitversion/src/core/topo-sort._test_.ts @@ -1,5 +1,5 @@ -import { IWorkspace } from './workspace-utils'; import { topoSort } from './topo-sort'; +import { IWorkspace } from './workspace-utils'; function makeWorkspace(packageName: string, deps: string[] = []): IWorkspace { return { From 7811de3cae6600118913e2001d561189c8589c8d Mon Sep 17 00:00:00 2001 From: Joost van der Waal Date: Sat, 6 Jun 2026 23:46:57 +0200 Subject: [PATCH 3/4] feat: added pack result --- .../packages/gitversion/src/commands/pack.ts | 63 ++++++++++++++----- .../gitversion/src/core/pack-artifact.ts | 1 + .../gitversion/src/core/plugin-manager.ts | 11 +++- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/workspaces/packages/gitversion/src/commands/pack.ts b/workspaces/packages/gitversion/src/commands/pack.ts index 18d664e..40b9531 100644 --- a/workspaces/packages/gitversion/src/commands/pack.ts +++ b/workspaces/packages/gitversion/src/commands/pack.ts @@ -9,7 +9,8 @@ import { cascade, isAtLeast, isNumber } from 'typanion'; import { Application, IApplication } from '../core/application'; import { Bump, BumpManifest } from '../core/bump-manifest'; import { formatFileSize, formatPackageName } from '../core/format-utils'; -import { PackArtifact } from '../core/pack-artifact'; +import { PackArtifact, PackedPackage } from '../core/pack-artifact'; +import { PackFileResult, PackResult } from '../core/plugin-manager'; import { topoSort } from '../core/topo-sort'; import { IWorkspace } from '../core/workspace-utils'; @@ -194,21 +195,46 @@ export class PackCommand extends GitVersionCommand { recursive: true, }); const packFile = await packManager.pack(workspace, folder); - if (packFile) { + let fileEntry: Record> = {}; + let metadataEntry: { ident: string, data: unknown } | null = null; + if (packFile !== null && typeof packFile === 'object' && 'files' in packFile && Array.isArray((packFile as PackResult).files)) { + // PackResult branch + const packResult = packFile as PackResult; + const fileNames: string[] = []; + const perFileMetadata: Record = {}; + for (const f of packResult.files as PackFileResult[]) { + fileNames.push(f.name); + if (f.metadata !== undefined) { + perFileMetadata[f.name] = f.metadata; + } + const fullName = join(folder, f.name); + const stats = await stat(fullName); + logger.reportInfo(`Generated package: ./${relative(application.cwd, fullName)} (${formatFileSize(stats.size)})`); + } + fileEntry = { + [packManager.ident]: fileNames, + }; + if (Object.keys(perFileMetadata).length > 0) { + metadataEntry = { + ident: packManager.ident, + data: perFileMetadata, + }; + } + } else if (packFile) { if (Array.isArray(packFile)) { for (const file of packFile) { const fullName = join(folder, file); const stats = await stat(fullName); logger.reportInfo(`Generated package: ./${relative(application.cwd, fullName)} (${formatFileSize(stats.size)})`); } - return { + fileEntry = { [packManager.ident]: packFile, }; - } if (typeof packFile === 'string') { + } else if (typeof packFile === 'string') { const fullName = join(folder, packFile); const stats = await stat(fullName); logger.reportInfo(`Generated package: ./${relative(application.cwd, fullName)} (${formatFileSize(stats.size)})`); - return { + fileEntry = { [packManager.ident]: packFile, }; } else if (typeof packFile === 'object' && packFile !== null) { @@ -220,29 +246,38 @@ export class PackCommand extends GitVersionCommand { logger.reportInfo(`Generated package: ./${relative(application.cwd, fullName)} (${formatFileSize(stats.size)})`); files[key] = value; } - return { + fileEntry = { [packManager.ident]: files, }; - } else { - return {}; } - } else { - return {}; } + + return { fileEntry, metadataEntry }; }); - const files = (await Promise.all(packCommands)).reduce((p: Record>, c) => { + const results = await Promise.all(packCommands); + + const files = results.reduce((p: Record>, { fileEntry }) => { return { ...p, - ...c, + ...fileEntry, }; }, {}); - packManifest.add({ + const packedPackage: PackedPackage = { packFiles: files, ...bump, republish: republish || undefined, - }); + }; + + for (const { metadataEntry } of results) { + if (metadataEntry) { + packedPackage.pluginData ??= {}; + packedPackage.pluginData[metadataEntry.ident] = metadataEntry.data; + } + } + + packManifest.add(packedPackage); } catch (error) { logger.reportError(`Error during pack: ${colorize.redBright(`${error}`)}`); throw error; diff --git a/workspaces/packages/gitversion/src/core/pack-artifact.ts b/workspaces/packages/gitversion/src/core/pack-artifact.ts index e0f8054..8852e7d 100644 --- a/workspaces/packages/gitversion/src/core/pack-artifact.ts +++ b/workspaces/packages/gitversion/src/core/pack-artifact.ts @@ -21,6 +21,7 @@ export interface PackedPackage { changeLog: ChangelogEntry; commits: ConventionalCommit[]; republish?: boolean; + pluginData?: Record; } export interface PackManifestGitStatus extends BumpManifestGitStatus { diff --git a/workspaces/packages/gitversion/src/core/plugin-manager.ts b/workspaces/packages/gitversion/src/core/plugin-manager.ts index 86c3229..5688816 100644 --- a/workspaces/packages/gitversion/src/core/plugin-manager.ts +++ b/workspaces/packages/gitversion/src/core/plugin-manager.ts @@ -17,9 +17,18 @@ export interface IGitPlatform { stripMergeMessage(commit: GitCommit): GitCommit; } +export interface PackFileResult { + name: string; + metadata?: unknown; +} + +export interface PackResult { + files: PackFileResult[]; +} + export interface IPackManager { ident: string; - pack(workspace: IWorkspace, outputFolder: string): Promise | null>; + pack(workspace: IWorkspace, outputFolder: string): Promise | PackResult | null>; publish(packedPackage: PackedPackage, fileName: string, releaseTag: string, dryRun: boolean, module?: string): Promise; } From 20bf588bd7692995a91eb066e3c44ae0ce0ac099 Mon Sep 17 00:00:00 2001 From: Joost van der Waal Date: Mon, 8 Jun 2026 09:48:54 +0200 Subject: [PATCH 4/4] fix: Chunk based processing of topological execution --- .../packages/gitversion/src/commands/pack.ts | 75 +++++++++---------- 1 file changed, 35 insertions(+), 40 deletions(-) diff --git a/workspaces/packages/gitversion/src/commands/pack.ts b/workspaces/packages/gitversion/src/commands/pack.ts index 40b9531..5213b86 100644 --- a/workspaces/packages/gitversion/src/commands/pack.ts +++ b/workspaces/packages/gitversion/src/commands/pack.ts @@ -3,7 +3,6 @@ import { colorize } from 'colorize-node'; import { mkdir, stat } from 'fs/promises'; import { cpus } from 'os'; import { join, relative } from 'path'; -import Queue from 'queue-promise'; import { cascade, isAtLeast, isNumber } from 'typanion'; import { Application, IApplication } from '../core/application'; @@ -101,24 +100,22 @@ export class PackCommand extends GitVersionCommand { .map(b => project.workspaces.find(w => w.relativeCwd === b.packageRelativeCwd)) .filter((w): w is IWorkspace => !!w); + const concurrency = this.maxConcurrency ?? cpus().length; for (const level of topoSort(workspacesToPack)) { - const queue = new Queue({ - concurrent: this.maxConcurrency ?? cpus().length, - start: false, - }); - level.forEach(workspace => { - const bump = bumpManifest.bumps.find(b => b.packageRelativeCwd === workspace.relativeCwd)!; - queue.enqueue(async () => { - try { - await this.execPackCommand(application, workspace, bump, packManifest, false); - } catch (error) { + for (let i = 0; i < level.length; i += concurrency) { + const chunk = level.slice(i, i + concurrency); + const chunkResults = await Promise.allSettled(chunk.map(async workspace => { + const bump = bumpManifest.bumps.find(b => b.packageRelativeCwd === workspace.relativeCwd)!; + await this.execPackCommand(application, workspace, bump, packManifest, false); + })); + for (const result of chunkResults) { + if (result.status === 'rejected') { hasErrors = true; - throw error; } - }); - }); - while (queue.shouldRun) { - await queue.dequeue(); + } + } + if (hasErrors) { + break; } } } @@ -134,33 +131,31 @@ export class PackCommand extends GitVersionCommand { const packFolder = join(configuration.stagingFolder, 'pack'); await mkdir(packFolder, { recursive: true }); + const concurrency = this.maxConcurrency ?? cpus().length; for (const level of topoSort(republishWorkspaces)) { - const queue = new Queue({ - concurrent: this.maxConcurrency ?? cpus().length, - start: false, - }); - level.forEach(workspace => { - queue.enqueue(async () => { - try { - const syntheticBump: Bump = { - packageRelativeCwd: workspace.relativeCwd, - packageName: workspace.packageName, - version: workspace.version, - previousVersion: workspace.version, - tag: workspace.tagPrefix + workspace.version, - private: false, - commits: [], - changeLog: { version: workspace.version, headerLine: '', body: '' }, - }; - await this.execPackCommand(application, workspace, syntheticBump, packManifest, true); - } catch (error) { + for (let i = 0; i < level.length; i += concurrency) { + const chunk = level.slice(i, i + concurrency); + const chunkResults = await Promise.allSettled(chunk.map(async workspace => { + const syntheticBump: Bump = { + packageRelativeCwd: workspace.relativeCwd, + packageName: workspace.packageName, + version: workspace.version, + previousVersion: workspace.version, + tag: workspace.tagPrefix + workspace.version, + private: false, + commits: [], + changeLog: { version: workspace.version, headerLine: '', body: '' }, + }; + await this.execPackCommand(application, workspace, syntheticBump, packManifest, true); + })); + for (const result of chunkResults) { + if (result.status === 'rejected') { hasErrors = true; - throw error; } - }); - }); - while (queue.shouldRun) { - await queue.dequeue(); + } + } + if (hasErrors) { + break; } } }