Skip to content

RFC - Semver redesign #655

@yjaaidi

Description

@yjaaidi

Context

The current version of semver (2.x) has some known limitations mainly because we designed it as an Nx executor.
Here are some examples:

  • performance issues as we have to run the executor on each project when running in independent mode
  • executor is hard to parallelize due to concurrent access to git
  • grouping commits is harder

The other major issue is that development workflows and project structures can vary a lot between workspaces. Thus, grouping and versioning strategies can vary a lot. Providing multiple options to cover all different use cases increases the surface of semver and can even make it confusing or false feature-rich.

Goals

In order to fix the issues above, semver 3 will be designed with the following goals:

  1. semver should run once on the whole workspace as a standalone script instead of a project executor: yarn semver or yarn nx semver.
  2. semver should allow extension using custom strategy implementations (e.g. semver.config.ts) instead of options.
sequenceDiagram
  Note over Core: 1. resolve strategy
  Core->>Core: resolveStrategy(): Strategy

  Note over Core: 2. build versionable tree based on nx dep graph

  Core->>Core: getNxProjects()

  Core->>Strategy: resolveVersionables(projects:  NxProject[]): VersionableInfo[]

  Note over Core,Strategy: resolve tag prefix for each versionable
  Core->>Strategy: resolveTagPrefix(versionable: VersionableInfo): string

  Note over Core: 3. resolve last version for each versionable
  Core->>Core: resolveLastVersion(tagPrefix: string): Version

  Note over Core: 4. resolve changes (commits + deps commits)
  Core->>Core: resolveChanges(paths: string[], since: string): Changes

  Note over Core: 5. Build versionable tree based on dep graph
  Core->>Core: resolveDependencies(versionableInfos: VersionableInfo[]): VersionableInfo & {deps: VersionableInfo[]}

  Note over Core: Group everything in Versionable object

  Note over Core,Strategy: bump
  Core->>Strategy: bump(...)

  Note over Core,Strategy: update files
  Core->>Strategy: updateFiles(...)

  Note over Core: commit 
  Core->>Strategy: commit(...)

  Note over Core: finalize
  Core->>Strategy: finalize(...)
Loading
classDiagram

VersionableNode o-- VersionableNode
VersionableInfo <|-- Versionable
Versionable <|-- VersionableNode


note for NxProject "all these properties are used by the strategy\n to group projects into versionables"

class NxProject {
  type: 'app' | 'lib';
  name: string;
  path: string;
  tags: string[];
}

class VersionableInfo {
  name: string;
  paths: string[];
}

class Versionable {
  changes: Change[];
  dependencies: Versionable[];
  tagPrefix: string;
  version: Version;
}
Loading

Raw draft notes

strategy = resolveConfig();

semver.getProjects(); 
// [{name: 'a', path: 'apps/a'}, {name: 'a-ui', path: 'libs/a/ui', tags: ...}, {name: 'x', path: 'libs/x'}]
      ||
      \/
strategy.resolveVersionables();
      ||
      \/
class Versionable {
  name: string;
  paths: string[];
}
// [{name: 'a', paths: ['apps/a', 'libs/a/ui']}, {name: 'x', paths: ['libs/x']}]
      ||
      \/
semver.buildGraph(); 
      ||
      \/
class VersionableWithDeps {
  name: string;
  paths: string[];
  deps: VersionableWithDeps[];
}
      ||
      \/
strategy.resolveTagPrefix()
      ||
      \/
class VersionableWithDeps+TagPrefix {
  name: string;
  paths: string[];
  deps: VersionableWithDeps[];
  tagPrefix: string;
}
      ||
      \/
semver.resolveLastVersion()
      ||
      \/
class {
  name: string;
  paths: string[];
  deps: VersionableWithDeps[];
  tagPrefix: string;
  version: string;
}
      ||
      \/
semver.computeChanges()
      ||
      \/
class {
  name: string;
  paths: string[];
  deps: Versionable...[];
  tagPrefix: string;
  version: string;
  commits: Commit[];
}

Resolve groups

interface Versionable {
  name: string;
  paths: string[];
  publishable: boolean; // ignore this for now
  changes: Changes[];
  deps: Versionable[];
}

type GroupResoverStrategy = (workspace: Workspace) => Versionable[];

// ex. independent
const independentStrategy: GroupResoverStrategy = (workspace) => {
  return workspace.getProjects();
}

// ex. sync
const syncStrategy: GroupResoverStrategy () => { return {name: 'my-workspace', path: '/'} }

// ex. group by nx tag
// workspace: 
// - apps/a (nx tag: scope:a)
// - apps/b (nx tag: scope:b)
// - libs/a/ui (nx tag: scope:a)
// - libs/a/core ...
groupByNxTag('scope')(workspace); // => [{name: 'a', paths: ['apps/a', 'libs/a/ui']}, {name: 'b', paths: ['apps/b']}]

Resolve tag prefix

type TagPrefixResolver = (versionable: Versionable) => string;

Resolve last version

git tag -l 'semver-*' --sort=-v:refname | head -1

Build graph

TODO

Filter deps

The default implementation of this step is filtering all publishable deps.

Given: A => B publishable & C

Then graph would be: A => C

Compute changes

Given the following versionables:

  • a: apps/a, libs/a/ui
  • x: libs/x
  • y: libs/y

& nx dep graph is apps/a => libs/a/ui => libs/x => libs/y

When a breaking change happens on y

Then

getChanges(a); // 
getChanges(x); // 1.0.0 => 1.0.1
getChanges(y); // {commits: [{type: 'breaking change', message: 'xxx'}]}
function bump(versionable) {
  const commits = getCommits(versionable.name);
  const depsChanges = getDeps(versionable.name).reduce((dep, acc) => ({...acc, [dep.name]: computeBumpVersion(...)}), {});
  const newVersion = computeBump(versionable, commits, depsChanges);
  return {
    version: newVersion
  }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesthelp wantedExtra attention is needed

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions