Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .autover/changes/79ed5525-5700-469f-aa6e-32d93d973400.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"Projects": [
{
"Name": "AutoVer",
"Type": "Minor",
"ChangelogMessages": [
"Add VersionFromTag, which takes the version from the repository's release tags instead of a project file, for a repository that has no artifact to carry one. Projects are listed by name only, no file is written when a release is cut, and the largest increment any of them asks for wins",
"Seed a project that carries no version yet from the new InitialVersion setting instead of failing. The version field is created - a Version element for a project file, a version element in a nuspec's metadata, or a LABEL appended to a Dockerfile - and takes InitialVersion as-is, so releases after that increment normally",
"Add InitialVersion, the version to start from when nothing carries one yet, defaulting to 0.1.0",
"--skip-version-tag-check is no longer needed, since a project without a version is seeded rather than rejected. It is still accepted, and ignored"
]
}
]
}
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ consumed by the next `autover changelog`.
| `ChangelogCategories` | none | Map a commit type (e.g. `feat`) to a changelog heading. |
| `TagFormat` | `release_{date}[_{iteration}]` | The git tag a release is tagged with. |
| `ReleaseNameFormat` | follows `TagFormat` | The human-readable release name. |
| `VersionFromTag` | `false` | Take the version from the repository's release tags instead of from a project file. |
| `InitialVersion` | `0.1.0` | The version to start from when nothing carries one yet - a tag-sourced repository with no release tag, or a project file with no version field. |

Each entry in `Projects` takes:

Expand Down Expand Up @@ -229,6 +231,69 @@ line - where building a changelog for the highest tag would describe the wrong
release and cover the wrong commit range. If no release tag is reachable at all
(an unmerged branch, or a shallow clone), the highest-ordering tag is used.

### Starting from nothing

A project that doesn't carry a version yet is **seeded** rather than rejected.
AutoVer creates the version field - a `<Version>` element in an unconditioned
`PropertyGroup`, a `<version>` in a nuspec's `metadata`, or an
`org.opencontainers.image.version` LABEL appended to a Dockerfile - and writes
`InitialVersion` into it, taken as-is rather than incremented. Every release after
that reads the field back and increments it in the ordinary way, so seeding only
ever happens once.

`InitialVersion` defaults to `0.1.0`. Set it to start somewhere else:

```json
{ "InitialVersion": "1.0.0" }
```

This is decided per project entry: an entry whose files already carry a version
takes it from there, and only one carrying none is seeded.

### Versioning from tags, with no project file

Some repositories have nothing to carry a version. A repository of shared CI
templates, for instance, publishes no package and builds no image - its releases
are consumed by pinned ref. A version written into a file there would be a version
nothing ever reads.

This is the model GitHub Actions uses: `action.yml` has no version field at all,
and an action's version is purely the ref you check out. `VersionFromTag` does the
same, taking the current version from the repository's own release tags:

```json
{
"Projects": [ { "Name": "ci" } ],
"VersionFromTag": true,
"InitialVersion": "1.0.0",
"TagFormat": "v{major}.{minor}.{patch}",
"ChangeFilesDetermineIncrementType": true
}
```

Projects are listed by **name only** - the name is what `autover change
--project-name` attaches to and what the changelog labels. There is no `Path`.

A release then reads the current version out of the latest release tag, increments
it, and tags the new one. Because nothing is written, `autover version` produces
**no commit**: the tag lands on `HEAD`, and the release's only content is whatever
`autover changelog` commits afterwards. On the very first release there is no tag
to read, so `InitialVersion` is used exactly as given rather than incremented.

Where several projects are listed, they share the single version the tag carries,
and the largest increment any of them asked for wins - a Major change anywhere
makes the release a Major one.

Four things are checked when the configuration is read:

* `TagFormat` must be version-based. A date-based tag carries no version, so there
would be nothing to read the current one back from.
* At least one project must be listed, so a change file has something to attach to.
* No project may specify `Path` or `Paths`. One tag carries one version, so a
file-backed project alongside a tag-sourced one has no single answer.
* `InitialVersion` only applies with `VersionFromTag`, and must be a valid three
part version.

### Switching the format on an existing repository

The version lives in the project file, not in the tag, so releases carry on
Expand Down
2 changes: 1 addition & 1 deletion src/AutoVer/Commands/CommandFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ private Command BuildVersionCommand()

Option<bool> skipVersionTagCheckOption = new("--skip-version-tag-check")
{
Description = $"Skip version tag check and increment projects even if some don't have a {ProjectConstants.VersionTag} tag."
Description = $"No longer needed: a project without a {ProjectConstants.VersionTag} tag is now seeded from InitialVersion rather than rejected. Accepted for compatibility and ignored."
};
Option<bool> noCommitOption = new("--no-commit") { Description = "Do not commit changes after versioning." };
Option<bool> noTagOption = new("--no-tag") { Description = "Do not add a Git Tag after versioning." };
Expand Down
167 changes: 155 additions & 12 deletions src/AutoVer/Commands/VersionCommand.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using AutoVer.Extensions;
using AutoVer.Exceptions;
using AutoVer.Models;
using AutoVer.Services;

Expand Down Expand Up @@ -32,21 +32,27 @@ public async Task ExecuteAsync(
return;
}

if (!optionSkipVersionTagCheck)
{
foreach (var availableProject in userConfiguration.Projects)
{
availableProject.EnsureProjectHasVersionTag();
}
}

IDictionary<string, IncrementType> projectIncrements = new Dictionary<string, IncrementType>();
if (userConfiguration.ChangeFilesDetermineIncrementType)
{
var changeFiles = await changeFileHandler.LoadChangeFilesFromRepository(userConfiguration.GitRoot);
projectIncrements = changeFileHandler.GetProjectIncrementTypesFromChangeFiles(changeFiles);
}

if (userConfiguration.VersionFromTag)
{
await ReleaseFromTagAsync(userConfiguration, projectIncrements, incrementType, optionUseVersion, optionNoCommit, optionNoTag);
return;
}

// A project file that carries no version yet is seeded with the configured initial version,
// taken as-is, and the element is created when it's written. Decided per container, since a
// container is one version: a container with some versions present takes its version from
// those, and only one with none needs seeding.
var repositorySeedVersion = HasNoVersionAnywhere(userConfiguration.Projects)
? userConfiguration.EffectiveInitialVersion
: null;

ThreePartVersion? maxNextVersion = null;
if (userConfiguration.UseSameVersionForAllProjects)
{
Expand Down Expand Up @@ -77,7 +83,7 @@ public async Task ExecuteAsync(
project.ProjectDefinition,
projectIncrementType,
availableProject.PrereleaseLabel,
optionUseVersion ?? maxNextVersion?.ToString() ?? localMaxVersion?.ToString());
optionUseVersion ?? repositorySeedVersion ?? maxNextVersion?.ToString() ?? localMaxVersion?.ToString());
}
}
else
Expand All @@ -99,7 +105,7 @@ public async Task ExecuteAsync(
project.ProjectDefinition,
projectIncrementType,
availableProject.PrereleaseLabel,
optionUseVersion ?? localMaxVersion?.ToString());
optionUseVersion ?? SeedVersionFor(availableProject, userConfiguration) ?? localMaxVersion?.ToString());
}
}
else
Expand All @@ -117,7 +123,7 @@ public async Task ExecuteAsync(
project.ProjectDefinition,
projectIncrementType,
availableProject.PrereleaseLabel,
optionUseVersion ?? localMaxVersion?.ToString());
optionUseVersion ?? SeedVersionFor(availableProject, userConfiguration) ?? localMaxVersion?.ToString());
}
}
}
Expand Down Expand Up @@ -160,12 +166,149 @@ public async Task ExecuteAsync(
}
}

/// <summary>
/// Whether nothing in the repository carries a version yet, in which case the whole repository is
/// seeded from the configured initial version rather than incremented from nothing.
/// </summary>
private static bool HasNoVersionAnywhere(List<ProjectContainer> projects)
{
var allProjects = projects.SelectMany(container => container.Projects).ToList();

return allProjects.Count > 0 &&
allProjects.All(project => string.IsNullOrEmpty(project.ProjectDefinition.Version));
}

/// <summary>
/// The version to seed a container with, or null when it already carries one. A container is one
/// version, so a container with any version present takes its version from that rather than
/// being seeded - only one with none needs it.
/// </summary>
private static string? SeedVersionFor(ProjectContainer container, UserConfiguration userConfiguration) =>
HasNoVersionAnywhere([container]) ? userConfiguration.EffectiveInitialVersion : null;

/// <summary>
/// Cuts a release for a repository whose version lives in its tags. There is no file to rewrite,
/// so nothing is staged and no version-bump commit is produced - the tag is applied to HEAD, and
/// the release's only content is whatever `autover changelog` commits afterwards.
/// </summary>
private async Task ReleaseFromTagAsync(
UserConfiguration userConfiguration,
IDictionary<string, IncrementType> projectIncrements,
IncrementType optionIncrementType,
string? optionUseVersion,
bool optionNoCommit,
bool optionNoTag)
{
var currentVersion = versionHandler.GetCurrentTagVersion(userConfiguration);

ThreePartVersion releaseVersion;
if (!string.IsNullOrEmpty(optionUseVersion))
{
if (!ThreePartVersion.TryParse(optionUseVersion, out releaseVersion))
throw new InvalidArgumentException($"The version '{optionUseVersion}' you are trying to update to is invalid.");
}
else
{
// Checked before the first-release branch below, so that "nothing was asked for" means
// no release whether or not the repository has released before. Deciding it afterwards
// let a first release be cut from an empty set of changes while every release after it
// was correctly declined.
var resolvedIncrementType = ResolveIncrementTypeFromTag(userConfiguration, projectIncrements, optionIncrementType);
if (resolvedIncrementType.Equals(IncrementType.None))
return;

var prereleaseLabel = userConfiguration.Projects
.Select(project => project.PrereleaseLabel)
.FirstOrDefault(label => !string.IsNullOrEmpty(label));

if (currentVersion is null)
{
// The first release takes the configured initial version as-is: there is no earlier
// release to increment from, so incrementing would skip the version that was asked
// for. The prerelease label still applies though - otherwise configuring one would
// be ignored on the first release and honoured on every release after it.
releaseVersion = ThreePartVersion.Parse(userConfiguration.EffectiveInitialVersion);
releaseVersion.PrereleaseLabel ??= prereleaseLabel;
}
else
{
releaseVersion = versionIncrementer.GetNextVersion(
currentVersion.ToString(),
resolvedIncrementType,
prereleaseLabel);
}
}

userConfiguration.ResolvedReleaseVersion = releaseVersion;

if (userConfiguration.PersistConfiguration && !userConfiguration.ChangeFilesDetermineIncrementType)
{
await configurationManager.ResetUserConfiguration(userConfiguration, new UserConfigurationResetRequest
{
IncrementType = true
});
}

if (optionNoCommit)
return;

// Only the configuration reset above can have staged anything; the release itself writes no file.
if (gitHandler.HasStagedChanges(userConfiguration))
gitHandler.CommitChanges(userConfiguration, versionHandler.GetNewReleaseName(userConfiguration));

if (!optionNoTag)
gitHandler.AddTag(userConfiguration, versionHandler.GetNewVersionTag(userConfiguration));
}

/// <summary>
/// One tag carries one version for the whole repository, so the largest increment any project
/// asked for wins - a Major change anywhere makes the release a Major one.
/// </summary>
private static IncrementType ResolveIncrementTypeFromTag(
UserConfiguration userConfiguration,
IDictionary<string, IncrementType> projectIncrements,
IncrementType optionIncrementType)
{
if (!userConfiguration.ChangeFilesDetermineIncrementType)
{
if (!optionIncrementType.Equals(IncrementType.None))
return optionIncrementType;

return userConfiguration.Projects
.Select(project => project.IncrementType)
.FirstOrDefault(increment => increment is not null) ?? userConfiguration.DefaultIncrementType;
}

var requested = userConfiguration.Projects
.Where(project => projectIncrements.ContainsKey(project.Name))
.Select(project => projectIncrements[project.Name])
.ToList();

if (requested.Contains(IncrementType.Major)) return IncrementType.Major;
if (requested.Contains(IncrementType.Minor)) return IncrementType.Minor;
if (requested.Contains(IncrementType.Patch)) return IncrementType.Patch;

return IncrementType.None;
}

// A single project (the common case) prints as a bare value, matching
// `changelog --release-name`'s convention for shell capture, e.g.
// VERSION=$(autover version --current). Multiple projects print
// labeled, since there's no single "the" version to capture bare.
private void PrintCurrentVersions(UserConfiguration userConfiguration)
{
if (userConfiguration.VersionFromTag)
{
var tagVersion = versionHandler.GetCurrentTagVersion(userConfiguration);
if (tagVersion is null)
throw new InvalidVersionTagException(
$"The Git repository '{userConfiguration.GitRoot}' has no release tag to read a version from yet. " +
"Please run 'autover version' first.");

toolInteractiveService.WriteLine(tagVersion.ToString());
return;
}

var allProjects = userConfiguration.Projects
.SelectMany(container => container.Projects)
.ToList();
Expand Down
15 changes: 0 additions & 15 deletions src/AutoVer/Extensions/ProjectExtensions.cs

This file was deleted.

32 changes: 32 additions & 0 deletions src/AutoVer/Models/UserConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,27 @@ public class UserConfiguration
/// Left null (rather than defaulted in place) so that rewriting this file doesn't stamp a
/// value into the config of every repo that never asked for one.
/// </summary>
/// <summary>
/// Used for the very first release of a repository whose version comes from its tags, where
/// there is no earlier tag to read one from. Taken as-is rather than incremented - it is the
/// version that first release carries.
/// </summary>
public const string DefaultInitialVersion = "0.1.0";

/// <summary>
/// Takes the version from the repository's release tags instead of from a project file, for a
/// repository that has no artifact to carry one - a shared CI templates repository, say, whose
/// releases are consumed by pinned ref rather than by a published package. Projects are then
/// listed by name only, and no file is written when a release is cut.
/// </summary>
public bool VersionFromTag { get; set; } = false;

/// <summary>
/// The version of the first release when <see cref="VersionFromTag"/> is set and no release tag
/// exists yet. Defaults to <see cref="DefaultInitialVersion"/>.
/// </summary>
public string? InitialVersion { get; set; }

public string? TagFormat { get; set; }

/// <summary>
Expand All @@ -46,6 +67,17 @@ public class UserConfiguration
/// </summary>
public string? ReleaseNameFormat { get; set; }

[JsonIgnore]
public string EffectiveInitialVersion =>
string.IsNullOrWhiteSpace(InitialVersion) ? DefaultInitialVersion : InitialVersion;

/// <summary>
/// The version this release will carry, worked out by the version command when
/// <see cref="VersionFromTag"/> is set. There is no project file to read it back from in that
/// case, so it is carried here for the tag and the changelog to render from.
/// </summary>
internal ThreePartVersion? ResolvedReleaseVersion { get; set; }

[JsonIgnore]
public string EffectiveTagFormat =>
string.IsNullOrWhiteSpace(TagFormat) ? DefaultTagFormat : TagFormat;
Expand Down
Loading
Loading