diff --git a/.autover/changes/79ed5525-5700-469f-aa6e-32d93d973400.json b/.autover/changes/79ed5525-5700-469f-aa6e-32d93d973400.json new file mode 100644 index 0000000..b4f45d2 --- /dev/null +++ b/.autover/changes/79ed5525-5700-469f-aa6e-32d93d973400.json @@ -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" + ] + } + ] +} diff --git a/README.md b/README.md index f441898..eb0933d 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 `` element in an unconditioned +`PropertyGroup`, a `` 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 diff --git a/src/AutoVer/Commands/CommandFactory.cs b/src/AutoVer/Commands/CommandFactory.cs index c944ae9..496d2b5 100644 --- a/src/AutoVer/Commands/CommandFactory.cs +++ b/src/AutoVer/Commands/CommandFactory.cs @@ -95,7 +95,7 @@ private Command BuildVersionCommand() Option 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 noCommitOption = new("--no-commit") { Description = "Do not commit changes after versioning." }; Option noTagOption = new("--no-tag") { Description = "Do not add a Git Tag after versioning." }; diff --git a/src/AutoVer/Commands/VersionCommand.cs b/src/AutoVer/Commands/VersionCommand.cs index 16149e2..bbae5a3 100644 --- a/src/AutoVer/Commands/VersionCommand.cs +++ b/src/AutoVer/Commands/VersionCommand.cs @@ -1,4 +1,4 @@ -using AutoVer.Extensions; +using AutoVer.Exceptions; using AutoVer.Models; using AutoVer.Services; @@ -32,14 +32,6 @@ public async Task ExecuteAsync( return; } - if (!optionSkipVersionTagCheck) - { - foreach (var availableProject in userConfiguration.Projects) - { - availableProject.EnsureProjectHasVersionTag(); - } - } - IDictionary projectIncrements = new Dictionary(); if (userConfiguration.ChangeFilesDetermineIncrementType) { @@ -47,6 +39,20 @@ public async Task ExecuteAsync( 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) { @@ -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 @@ -99,7 +105,7 @@ public async Task ExecuteAsync( project.ProjectDefinition, projectIncrementType, availableProject.PrereleaseLabel, - optionUseVersion ?? localMaxVersion?.ToString()); + optionUseVersion ?? SeedVersionFor(availableProject, userConfiguration) ?? localMaxVersion?.ToString()); } } else @@ -117,7 +123,7 @@ public async Task ExecuteAsync( project.ProjectDefinition, projectIncrementType, availableProject.PrereleaseLabel, - optionUseVersion ?? localMaxVersion?.ToString()); + optionUseVersion ?? SeedVersionFor(availableProject, userConfiguration) ?? localMaxVersion?.ToString()); } } } @@ -160,12 +166,149 @@ public async Task ExecuteAsync( } } + /// + /// 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. + /// + private static bool HasNoVersionAnywhere(List projects) + { + var allProjects = projects.SelectMany(container => container.Projects).ToList(); + + return allProjects.Count > 0 && + allProjects.All(project => string.IsNullOrEmpty(project.ProjectDefinition.Version)); + } + + /// + /// 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. + /// + private static string? SeedVersionFor(ProjectContainer container, UserConfiguration userConfiguration) => + HasNoVersionAnywhere([container]) ? userConfiguration.EffectiveInitialVersion : null; + + /// + /// 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. + /// + private async Task ReleaseFromTagAsync( + UserConfiguration userConfiguration, + IDictionary 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)); + } + + /// + /// 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. + /// + private static IncrementType ResolveIncrementTypeFromTag( + UserConfiguration userConfiguration, + IDictionary 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(); diff --git a/src/AutoVer/Extensions/ProjectExtensions.cs b/src/AutoVer/Extensions/ProjectExtensions.cs deleted file mode 100644 index 911f218..0000000 --- a/src/AutoVer/Extensions/ProjectExtensions.cs +++ /dev/null @@ -1,15 +0,0 @@ -using AutoVer.Exceptions; - -namespace AutoVer.Extensions; - -public static class ProjectExtensions -{ - public static void EnsureProjectHasVersionTag(this Models.ProjectContainer projectContainer) - { - foreach (var project in projectContainer.Projects) - { - if (string.IsNullOrEmpty(project.ProjectDefinition.Version)) - throw new NoVersionTagException($"The project '{projectContainer.Name}' does not have a version defined. Add one and run the tool again."); - } - } -} diff --git a/src/AutoVer/Models/UserConfiguration.cs b/src/AutoVer/Models/UserConfiguration.cs index c10f2ff..5e2d53b 100644 --- a/src/AutoVer/Models/UserConfiguration.cs +++ b/src/AutoVer/Models/UserConfiguration.cs @@ -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. /// + /// + /// 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. + /// + public const string DefaultInitialVersion = "0.1.0"; + + /// + /// 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. + /// + public bool VersionFromTag { get; set; } = false; + + /// + /// The version of the first release when is set and no release tag + /// exists yet. Defaults to . + /// + public string? InitialVersion { get; set; } + public string? TagFormat { get; set; } /// @@ -46,6 +67,17 @@ public class UserConfiguration /// public string? ReleaseNameFormat { get; set; } + [JsonIgnore] + public string EffectiveInitialVersion => + string.IsNullOrWhiteSpace(InitialVersion) ? DefaultInitialVersion : InitialVersion; + + /// + /// The version this release will carry, worked out by the version command when + /// 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. + /// + internal ThreePartVersion? ResolvedReleaseVersion { get; set; } + [JsonIgnore] public string EffectiveTagFormat => string.IsNullOrWhiteSpace(TagFormat) ? DefaultTagFormat : TagFormat; diff --git a/src/AutoVer/Models/VersionTagFormat.cs b/src/AutoVer/Models/VersionTagFormat.cs index 8d03899..f800253 100644 --- a/src/AutoVer/Models/VersionTagFormat.cs +++ b/src/AutoVer/Models/VersionTagFormat.cs @@ -136,6 +136,12 @@ private static CultureInfo CreateDateCulture() /// public bool SupportsIteration { get; } + /// + /// Whether the format can carry a prerelease label. It matters most where the tag is the only + /// place a version lives: a label the format can't render is simply lost. + /// + public bool SupportsPrerelease { get; } + private VersionTagFormat( string format, List segments, @@ -150,6 +156,7 @@ private VersionTagFormat( _segments = segments; Family = family; SupportsIteration = supportsIteration; + SupportsPrerelease = kinds.Contains(TagPlaceholder.Prerelease); _matcher = matcher; _dateGroups = dateGroups; } diff --git a/src/AutoVer/Services/ChangelogHandler.cs b/src/AutoVer/Services/ChangelogHandler.cs index 69d6caf..f0f72d6 100644 --- a/src/AutoVer/Services/ChangelogHandler.cs +++ b/src/AutoVer/Services/ChangelogHandler.cs @@ -115,7 +115,11 @@ public async Task GenerateChangelog(UserConfiguration configurat var changelogCategory = new ChangelogCategory { Name = configuredProject.Name, - Version = configuredProject.Projects.First().ProjectDefinition.Version + // A tag-sourced project has no project file to read a version from, so + // the release tag itself is the only place it exists. + Version = configuration.VersionFromTag + ? versionHandler.GetCurrentTagVersion(configuration)?.ToString() + : configuredProject.Projects.First().ProjectDefinition.Version }; if (!configuration.UseSameVersionForAllProjects) diff --git a/src/AutoVer/Services/ConfigurationManager.cs b/src/AutoVer/Services/ConfigurationManager.cs index 5cd0dd0..110afd9 100644 --- a/src/AutoVer/Services/ConfigurationManager.cs +++ b/src/AutoVer/Services/ConfigurationManager.cs @@ -84,7 +84,10 @@ public async Task RetrieveUserConfiguration(string? projectPa if (userConfiguration is null && !string.IsNullOrEmpty(tagName)) userConfiguration = await LoadUserConfigurationFromRepository(gitRoot); - if (userConfiguration?.Projects?.Any() ?? false) + // Discovery is also skipped when the version comes from the repository's tags: there are no + // project files to find, so scanning for them would fail with a message about missing + // .csproj/.nuspec/Dockerfile files instead of the real problem. + if ((userConfiguration?.Projects?.Any() ?? false) || (userConfiguration?.VersionFromTag ?? false)) { userConfiguration.GitRoot = gitRoot; userConfiguration.PersistConfiguration = true; @@ -126,6 +129,7 @@ public async Task RetrieveUserConfiguration(string? projectPa throw new InvalidProjectException("The project path you have specified is not a valid git repository."); ValidateTagFormats(userConfiguration); + ValidateVersionFromTag(userConfiguration); return userConfiguration; } @@ -189,7 +193,10 @@ private void ValidateTagFormats(UserConfiguration userConfiguration) $"{releaseNameFormat.Family.ToString().ToLowerInvariant()}-based ('{releaseNameFormat.Format}'). " + "Both must use the same family of placeholders."); + // Exempt when the version comes from the tag: every project then reads that one version, so + // they cannot disagree in the way this rule guards against. if (tagFormat.Family == TagFormatFamily.Semver && + !userConfiguration.VersionFromTag && !userConfiguration.UseSameVersionForAllProjects && userConfiguration.Projects.Count > 1) throw new InvalidUserConfigurationException( @@ -243,6 +250,71 @@ await JsonSerializer.SerializeAsync( } } + /// + /// Checks the combination of settings a tag-sourced version depends on, at load time rather than + /// part-way through a release. + /// + private static void ValidateVersionFromTag(UserConfiguration userConfiguration) + { + // InitialVersion applies to any repository - it is also what seeds a project file that + // doesn't carry a version yet - so it is validated regardless of where the version comes from. + if (!ThreePartVersion.TryParse(userConfiguration.EffectiveInitialVersion, out _)) + throw new InvalidUserConfigurationException( + $"'{nameof(UserConfiguration.InitialVersion)}' ('{userConfiguration.EffectiveInitialVersion}') is not " + + "a valid three part version."); + + if (!userConfiguration.VersionFromTag) + return; + + + + // The tag is the only place the version lives, so it has to be able to carry one - a + // date-based tag would leave nothing to read the current version back from. + var tagFormat = VersionTagFormat.Parse( + userConfiguration.EffectiveTagFormat, + nameof(UserConfiguration.TagFormat)); + if (tagFormat.Family != TagFormatFamily.Semver) + throw new InvalidUserConfigurationException( + $"'{nameof(UserConfiguration.VersionFromTag)}' needs a version-based " + + $"'{nameof(UserConfiguration.TagFormat)}' to read the version back from, but " + + $"'{tagFormat.Format}' is date-based. Use a format built from {{major}}, {{minor}} and " + + "{patch}, e.g. 'v{major}.{minor}.{patch}'."); + + // The tag is the only place a tag-sourced version lives, so a prerelease label the format + // can't render isn't merely absent from the tag - it's lost outright, and every release would + // silently come out as a plain version. A file-backed project at least keeps it in the file. + var prereleaseProjects = userConfiguration.Projects + .Where(project => !string.IsNullOrEmpty(project.PrereleaseLabel)) + .Select(project => project.Name) + .ToList(); + if (prereleaseProjects.Count > 0 && !tagFormat.SupportsPrerelease) + throw new InvalidUserConfigurationException( + $"{string.Join(", ", prereleaseProjects.Select(name => $"'{name}'"))} sets a PrereleaseLabel, but " + + $"'{nameof(UserConfiguration.TagFormat)}' ('{tagFormat.Format}') has no {{prerelease}} placeholder to " + + "carry it - and with the version coming from the tag, there is nowhere else for it to live. Add an " + + "optional prerelease group, e.g. 'v{major}.{minor}.{patch}[-{prerelease}]'."); + + if (userConfiguration.Projects.Count == 0) + throw new InvalidUserConfigurationException( + $"'{nameof(UserConfiguration.VersionFromTag)}' is set but no projects are listed. List at least " + + "one project by name, so a change file has something to attach to and the changelog has something " + + "to label, e.g. \"Projects\": [ { \"Name\": \"my-repo\" } ]."); + + // One tag carries one version, so a project reading its version from a file alongside one + // reading it from the tag would leave no single answer for what the tag represents. + var withPaths = userConfiguration.Projects + .Where(project => project.GetPaths().Count > 0) + .Select(project => project.Name) + .ToList(); + if (withPaths.Count > 0) + throw new InvalidUserConfigurationException( + $"'{nameof(UserConfiguration.VersionFromTag)}' is set, so every project takes its version from the " + + $"repository's tags - but {string.Join(", ", withPaths.Select(name => $"'{name}'"))} still " + + "specifies a Path or Paths. Remove them, or turn " + + $"'{nameof(UserConfiguration.VersionFromTag)}' off."); + + } + private string GetProjectName(string projectPath) => projectFileHandlerResolver.Resolve(projectPath).GetDisplayName(projectPath); diff --git a/src/AutoVer/Services/Converters/UserConfigurationConverter.cs b/src/AutoVer/Services/Converters/UserConfigurationConverter.cs index 4d41ac1..acd46aa 100644 --- a/src/AutoVer/Services/Converters/UserConfigurationConverter.cs +++ b/src/AutoVer/Services/Converters/UserConfigurationConverter.cs @@ -19,8 +19,11 @@ public class UserConfigurationConverter( UserConfiguration? obj = JsonSerializer.Deserialize(ref reader, defaultOptions); - // Inject the dependency into the deserialized object - if (obj != null) + // Inject the dependency into the deserialized object. Skipped entirely when the version + // comes from the repository's tags: those projects are listed by name only, so there is no + // project file to load, and the Path/Paths requirement deliberately still applies to every + // other configuration. + if (obj != null && !obj.VersionFromTag) { foreach (var container in obj.Projects) { diff --git a/src/AutoVer/Services/IVersionHandler.cs b/src/AutoVer/Services/IVersionHandler.cs index 19f5c79..5f9d4ac 100644 --- a/src/AutoVer/Services/IVersionHandler.cs +++ b/src/AutoVer/Services/IVersionHandler.cs @@ -10,4 +10,11 @@ public interface IVersionHandler string GetCurrentVersionTag(UserConfiguration configuration); string GetCurrentReleaseName(UserConfiguration configuration); string? GetLastVersionTag(UserConfiguration configuration); + + /// + /// The version carried by the current release tag, or null when the repository has no release + /// yet. For a repository whose version comes from its tags, this is where the current version is + /// read from instead of a project file. + /// + ThreePartVersion? GetCurrentTagVersion(UserConfiguration configuration); } \ No newline at end of file diff --git a/src/AutoVer/Services/ProjectFiles/CsprojNuspecFileHandler.cs b/src/AutoVer/Services/ProjectFiles/CsprojNuspecFileHandler.cs index 18cb1f3..c388c1e 100644 --- a/src/AutoVer/Services/ProjectFiles/CsprojNuspecFileHandler.cs +++ b/src/AutoVer/Services/ProjectFiles/CsprojNuspecFileHandler.cs @@ -42,13 +42,21 @@ public void UpdateVersion(ProjectDefinition projectDefinition, IncrementType inc var xmlProjectFile = projectDefinition.GetContents(); var versionTagName = GetVersionTagName(projectDefinition.ProjectPath); var versionTagList = xmlProjectFile.GetElementsByTagName(versionTagName).Cast().ToList(); - if (!versionTagList.Any()) - throw new NoVersionTagException($"The project '{projectDefinition.ProjectPath}' does not have a {ProjectConstants.VersionTag} tag. Add a {ProjectConstants.VersionTag} tag and run the tool again."); - var versionTag = versionTagList.First(); + // A project carrying no version yet is seeded rather than rejected: the element is created, + // and the version it gets is taken as-is rather than incremented from nothing. + var seeded = !versionTagList.Any(); + var versionTag = seeded + ? CreateVersionElement(xmlProjectFile, versionTagName, projectDefinition.ProjectPath) + : versionTagList.First(); + if (string.IsNullOrEmpty(overrideVersion)) { - var nextVersion = versionIncrementer.GetNextVersion(versionTag.InnerText, incrementType, prereleaseLabel); + var nextVersion = seeded + // The caller normally supplies the version to seed with; this is only the fallback + // for a direct call that didn't. + ? versionIncrementer.GetCurrentVersion(null) + : versionIncrementer.GetNextVersion(versionTag.InnerText, incrementType, prereleaseLabel); versionTag.InnerText = nextVersion.ToString(); } else @@ -71,6 +79,74 @@ public void UpdateVersion(ProjectDefinition projectDefinition, IncrementType inc xmlProjectFile.Save(stream); } + /// + /// Creates the version element for a project that doesn't have one. A nuspec's version belongs + /// in its metadata element; a project file's belongs in a PropertyGroup, and specifically in an + /// unconditioned one - a version inside a Condition would only apply to some builds. + /// + private static XmlNode CreateVersionElement(XmlDocument document, string versionTagName, string projectPath) + { + var root = document.DocumentElement + ?? throw new InvalidProjectException($"The project '{projectPath}' has no root element."); + + XmlNode parent; + if (versionTagName.Equals(ProjectConstants.NuspecVersionTag, StringComparison.Ordinal)) + { + parent = document.GetElementsByTagName("metadata").Cast().FirstOrDefault() + ?? throw new InvalidProjectException( + $"The nuspec '{projectPath}' has no 'metadata' element to add a '{ProjectConstants.NuspecVersionTag}' to."); + } + else + { + var unconditioned = document.GetElementsByTagName("PropertyGroup").Cast() + .FirstOrDefault(group => group.Attributes?["Condition"] is null); + + if (unconditioned is null) + { + unconditioned = document.CreateElement("PropertyGroup", root.NamespaceURI); + InsertAsLastElement(document, root, unconditioned); + } + + parent = unconditioned; + } + + var element = document.CreateElement(versionTagName, parent.NamespaceURI); + InsertAsLastElement(document, parent, element); + return element; + } + + /// + /// Adds a node as the last element child of its parent while keeping the file's layout. The + /// document is loaded with PreserveWhitespace, so indentation is made of real text nodes: the + /// whitespace before the closing tag is a node of its own, and simply appending would put the new + /// element after it and leave the closing tag on the same line. + /// + private static void InsertAsLastElement(XmlDocument document, XmlNode parent, XmlNode node) + { + static bool IsWhitespace(XmlNode? candidate) => + candidate is { NodeType: XmlNodeType.Whitespace or XmlNodeType.SignificantWhitespace }; + + // The indentation an existing sibling sits behind, so the new element lines up with it + // rather than with the closing tag. + var firstElement = parent.ChildNodes.Cast() + .FirstOrDefault(child => child.NodeType == XmlNodeType.Element); + var indent = IsWhitespace(firstElement?.PreviousSibling) ? firstElement!.PreviousSibling!.Value : null; + + if (IsWhitespace(parent.LastChild)) + { + parent.InsertBefore(node, parent.LastChild!); + if (!string.IsNullOrEmpty(indent)) + parent.InsertBefore(document.CreateWhitespace(indent), node); + + return; + } + + if (!string.IsNullOrEmpty(indent)) + parent.AppendChild(document.CreateWhitespace(indent)); + + parent.AppendChild(node); + } + private static string GetVersionTagName(string projectPath) { var extension = Path.GetExtension(projectPath); diff --git a/src/AutoVer/Services/ProjectFiles/DockerfileFileHandler.cs b/src/AutoVer/Services/ProjectFiles/DockerfileFileHandler.cs index 18b9781..2cb14cd 100644 --- a/src/AutoVer/Services/ProjectFiles/DockerfileFileHandler.cs +++ b/src/AutoVer/Services/ProjectFiles/DockerfileFileHandler.cs @@ -59,28 +59,40 @@ public void UpdateVersion(ProjectDefinition projectDefinition, IncrementType inc { var lines = projectDefinition.GetContents>(); var lineIndexes = FindVersionLabelLines(lines); - if (lineIndexes.Count == 0) - throw new NoVersionTagException($"The Dockerfile '{projectDefinition.ProjectPath}' does not have a '{ProjectConstants.DockerImageVersionLabel}' LABEL. Add one and run the tool again."); string newVersion; - if (string.IsNullOrEmpty(overrideVersion)) - { - var currentVersion = VersionLabelRegex.Match(lines[lineIndexes[0]]).Groups["version"].Value; - newVersion = versionIncrementer.GetNextVersion(currentVersion, incrementType, prereleaseLabel).ToString(); - } - else + if (!string.IsNullOrEmpty(overrideVersion)) { if (!ThreePartVersion.TryParse(overrideVersion, out var version)) throw new InvalidArgumentException($"The version '{overrideVersion}' you are trying to update to is invalid."); newVersion = version.ToString(); } + else if (lineIndexes.Count == 0) + { + // A Dockerfile carrying no version yet is seeded rather than rejected. The caller + // normally supplies the version to seed with; this is only the fallback for a direct + // call that didn't, and it is taken as-is rather than incremented. + newVersion = versionIncrementer.GetCurrentVersion(null).ToString(); + } + else + { + var currentVersion = VersionLabelRegex.Match(lines[lineIndexes[0]]).Groups["version"].Value; + newVersion = versionIncrementer.GetNextVersion(currentVersion, incrementType, prereleaseLabel).ToString(); + } - // Multi-stage Dockerfiles can repeat the same version LABEL per stage; keep every - // occurrence in sync rather than only updating the first one found. - foreach (var lineIndex in lineIndexes) + if (lineIndexes.Count == 0) + { + AppendVersionLabel(lines, newVersion); + } + else { - lines[lineIndex] = VersionLabelRegex.Replace(lines[lineIndex], match => - $"{match.Groups["prefix"].Value}{newVersion}{match.Groups["suffix"].Value}"); + // Multi-stage Dockerfiles can repeat the same version LABEL per stage; keep every + // occurrence in sync rather than only updating the first one found. + foreach (var lineIndex in lineIndexes) + { + lines[lineIndex] = VersionLabelRegex.Replace(lines[lineIndex], match => + $"{match.Groups["prefix"].Value}{newVersion}{match.Groups["suffix"].Value}"); + } } // Keep the in-memory definition in step with what was just written - a version-based tag @@ -94,6 +106,29 @@ public void UpdateVersion(ProjectDefinition projectDefinition, IncrementType inc // any of its backslash-continuation lines (e.g. "LABEL a=\"1\" \" followed by a line // holding the actual version key=value pair), and collects every line within one that // matches the version label. + /// + /// Adds the version LABEL to a Dockerfile that has none. Appended at the end of the file, which + /// puts it in the final build stage - the stage whose labels the built image actually carries - + /// without having to interpret the file's structure. + /// + private static void AppendVersionLabel(List lines, string version) + { + var label = $"LABEL {ProjectConstants.DockerImageVersionLabel}=\"{version}\""; + + // Lines keep their own trailing '\r', so match whatever the file already uses rather than + // leaving it with mixed endings. + if (lines.Any(line => line.EndsWith('\r'))) + label += '\r'; + + // A file ending in a newline splits to a trailing empty entry; inserting before it keeps the + // trailing newline and avoids introducing a blank line. + var insertAt = lines.Count > 0 && string.IsNullOrWhiteSpace(lines[^1]) + ? lines.Count - 1 + : lines.Count; + + lines.Insert(insertAt, label); + } + private static List FindVersionLabelLines(List lines) { var lineIndexes = new List(); diff --git a/src/AutoVer/Services/VersionHandler.cs b/src/AutoVer/Services/VersionHandler.cs index 3b717a4..c89690b 100644 --- a/src/AutoVer/Services/VersionHandler.cs +++ b/src/AutoVer/Services/VersionHandler.cs @@ -104,6 +104,17 @@ public string GetCurrentReleaseName(UserConfiguration configuration) : null; } + public ThreePartVersion? GetCurrentTagVersion(UserConfiguration configuration) + { + var format = GetTagFormat(configuration); + + // No release yet is an ordinary state here, not an error: it is how the first release of a + // tag-sourced repository is recognised. + return GetVersionTags(configuration.GitRoot, format).Count == 0 + ? null + : GetCurrentTag(configuration.GitRoot, format).Version; + } + private VersionTag GetCurrentTag(string gitRoot, VersionTagFormat format) { // Generating a changelog asks for the current release several times over - its name, its @@ -170,6 +181,11 @@ private int GetNextIteration(UserConfiguration configuration, VersionTagFormat f /// private static ThreePartVersion? GetReleaseVersion(UserConfiguration configuration) { + // Set when the version came from the repository's tags rather than a project file, in which + // case there is no file to read it back from. + if (configuration.ResolvedReleaseVersion is not null) + return configuration.ResolvedReleaseVersion; + ThreePartVersion? highest = null; foreach (var project in configuration.Projects.SelectMany(container => container.Projects)) diff --git a/test/AutoVer.IntegrationTests/DockerfileVersionCommandTests.cs b/test/AutoVer.IntegrationTests/DockerfileVersionCommandTests.cs index 7208cd6..b991a54 100644 --- a/test/AutoVer.IntegrationTests/DockerfileVersionCommandTests.cs +++ b/test/AutoVer.IntegrationTests/DockerfileVersionCommandTests.cs @@ -189,10 +189,10 @@ public async Task NoTagOption_CommitsButSkipsTag() await Assert.That(GitUtilities.GetAllTags(_tempDir)).IsEmpty(); } - // J. A Dockerfile missing the version LABEL fails cleanly (no raw stack trace) with a - // non-zero exit code, with and without `--skip-version-tag-check`. + // J. A Dockerfile with no version LABEL is seeded from the configured initial version rather + // than rejected - the label is created, so the release after this one increments normally. [Test] - public async Task MissingVersionLabel_FailsCleanly_WithAndWithoutSkipCheck() + public async Task MissingVersionLabel_IsSeededFromTheInitialVersion() { var dockerfilePath = await SetUpSingleDockerfileRepo("MyImage", "1.0.0", changeFilesDetermineIncrementType: false); await IOUtilities.RemoveDockerfileVersionLabel(dockerfilePath); @@ -200,12 +200,17 @@ public async Task MissingVersionLabel_FailsCleanly_WithAndWithoutSkipCheck() GitUtilities.CommitChanges(_tempDir, "Remove version label"); var (exitCode, _, error) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir]); - await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.UserError); - await Assert.That(error).DoesNotContain("at AutoVer."); - var (skipExitCode, _, skipError) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir, "--skip-version-tag-check"]); - await Assert.That(skipExitCode).IsEqualTo(CommandReturnCodes.UserError); - await Assert.That(skipError).DoesNotContain("at AutoVer."); + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(error).DoesNotContain("at AutoVer."); + // The default initial version, taken as-is rather than incremented. + await Assert.That(await IOUtilities.GetDockerfileVersion(dockerfilePath)) + .IsEqualTo(UserConfiguration.DefaultInitialVersion); + + // The label now exists, so the next release increments from it in the ordinary way. + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + await Assert.That(await IOUtilities.GetDockerfileVersion(dockerfilePath)).IsEqualTo("0.1.1"); } // K. Running `version` twice on the same calendar day produces `release_` then diff --git a/test/AutoVer.IntegrationTests/Utilities/GitUtilities.cs b/test/AutoVer.IntegrationTests/Utilities/GitUtilities.cs index d4e65a8..8cb795a 100644 --- a/test/AutoVer.IntegrationTests/Utilities/GitUtilities.cs +++ b/test/AutoVer.IntegrationTests/Utilities/GitUtilities.cs @@ -55,6 +55,17 @@ public static void CommitChanges(string gitRepositoryPath, string commitMessage) gitRepository.Commit(commitMessage, signature, signature); } + /// + /// Moves an existing tag onto HEAD, as the release pipeline does after writing the changelog so + /// that the tag covers the whole release rather than the commit before it. + /// + public static void MoveTagToHead(string gitRepositoryPath, string tagName) + { + using var repo = new Repository(gitRepositoryPath); + repo.Tags.Remove(tagName); + repo.ApplyTag(tagName, repo.Head.Tip.Sha); + } + public static string GetCurrentBranch(string gitRepositoryPath) { using var repo = new Repository(gitRepositoryPath); diff --git a/test/AutoVer.IntegrationTests/VersionCommandTests.cs b/test/AutoVer.IntegrationTests/VersionCommandTests.cs index f331830..b482582 100644 --- a/test/AutoVer.IntegrationTests/VersionCommandTests.cs +++ b/test/AutoVer.IntegrationTests/VersionCommandTests.cs @@ -266,11 +266,10 @@ public async Task NoTagOption_CommitsButSkipsTag() await Assert.That(GitUtilities.GetAllTags(_tempDir)).IsEmpty(); } - // J. A project missing a tag fails cleanly (no raw stack trace) with a - // non-zero exit code. `--skip-version-tag-check` bypasses the pre-flight check but - // still fails at write time, since there is genuinely nothing to update. + // J. A project with no tag is seeded from the configured initial version rather than + // rejected - the element is created, so the release after this one increments normally. [Test] - public async Task MissingVersionTag_FailsCleanly_WithAndWithoutSkipCheck() + public async Task MissingVersionTag_IsSeededFromTheInitialVersion() { var csprojPath = await SetUpSingleProjectRepo("Project1", "1.0.0", changeFilesDetermineIncrementType: false); await IOUtilities.RemoveProjectVersionTag(csprojPath); @@ -278,12 +277,68 @@ public async Task MissingVersionTag_FailsCleanly_WithAndWithoutSkipCheck() GitUtilities.CommitChanges(_tempDir, "Remove version tag"); var (exitCode, _, error) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir]); - await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.UserError); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); await Assert.That(error).DoesNotContain("at AutoVer."); + // The default initial version, taken as-is rather than incremented. + await Assert.That(await IOUtilities.GetProjectVersion(csprojPath)) + .IsEqualTo(UserConfiguration.DefaultInitialVersion); + + // The element now exists, so the next release increments from it in the ordinary way. + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + await Assert.That(await IOUtilities.GetProjectVersion(csprojPath)).IsEqualTo("0.1.1"); + } + + // A version field that exists but is empty is the same situation as one that is missing - the + // project carries no version - so it is seeded rather than incremented from nothing. + [Test] + public async Task EmptyVersionTag_IsSeededFromTheInitialVersion() + { + await IOUtilities.CreateProject(_tempDir, "src", "Project1"); + var csprojPath = Path.Combine(_tempDir, "src", "Project1", "Project1.csproj"); + await IOUtilities.SetProjectVersion(csprojPath, "1.0.0"); + var withEmptyVersion = (await File.ReadAllTextAsync(csprojPath)) + .Replace("1.0.0", ""); + await File.WriteAllTextAsync(csprojPath, withEmptyVersion); + + await IOUtilities.AddAutoVerFile(_tempDir, + @"{ + ""Projects"": [ { ""Name"": ""Project1"", ""Path"": ""src/Project1/Project1.csproj"" } ], + ""UseCommitsForChangelog"": false, + ""ChangeFilesDetermineIncrementType"": false, + ""InitialVersion"": ""5.0.0"" +}"); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Initial Commit"); + + var exitCode = await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir]); - var (skipExitCode, _, skipError) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir, "--skip-version-tag-check"]); - await Assert.That(skipExitCode).IsEqualTo(CommandReturnCodes.UserError); - await Assert.That(skipError).DoesNotContain("at AutoVer."); + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(await IOUtilities.GetProjectVersion(csprojPath)).IsEqualTo("5.0.0"); + } + + // A configured InitialVersion applies to a project file too, not only to a tag-sourced + // repository - the seeded version is the one that was asked for. + [Test] + public async Task MissingVersionTag_UsesAConfiguredInitialVersion() + { + var csprojPath = await SetUpSingleProjectRepo("Project1", "1.0.0", changeFilesDetermineIncrementType: false); + await IOUtilities.RemoveProjectVersionTag(csprojPath); + await IOUtilities.AddAutoVerFile(_tempDir, + @"{ + ""Projects"": [ { ""Name"": ""Project1"", ""Path"": ""src/Project1/Project1.csproj"" } ], + ""UseCommitsForChangelog"": false, + ""ChangeFilesDetermineIncrementType"": false, + ""InitialVersion"": ""2.5.0"" +}"); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Remove version tag"); + + var exitCode = await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(await IOUtilities.GetProjectVersion(csprojPath)).IsEqualTo("2.5.0"); } // K. Running `version` twice on the same calendar day produces `release_` then diff --git a/test/AutoVer.IntegrationTests/VersionFromTagCommandTests.cs b/test/AutoVer.IntegrationTests/VersionFromTagCommandTests.cs new file mode 100644 index 0000000..f273e7e --- /dev/null +++ b/test/AutoVer.IntegrationTests/VersionFromTagCommandTests.cs @@ -0,0 +1,383 @@ +using AutoVer.Constants; +using AutoVer.IntegrationTests.Utilities; +using AutoVer.Models; +using LibGit2Sharp; + +namespace AutoVer.IntegrationTests; + +/// +/// A repository whose version lives in its release tags rather than in a project file - the shape of +/// a shared CI templates repository, which has no artifact to carry a version and is consumed by +/// pinned ref. There is deliberately no .csproj, .nuspec or Dockerfile anywhere in these repos. +/// +[Retry(3)] +public class VersionFromTagCommandTests +{ + private const string SemverTagFormat = "v{major}.{minor}.{patch}"; + + private string _tempDir = string.Empty; + + [Before(Test)] + public void Before() + { + _tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_tempDir); + Repository.Init(_tempDir); + using var repo = new Repository(_tempDir); + _tempDir = repo.Info.WorkingDirectory; + IOUtilities.AddGitignore(_tempDir); + } + + [After(Test)] + public void After() + { + try + { + if (!string.IsNullOrEmpty(_tempDir) && Directory.Exists(_tempDir)) + { + IOUtilities.RemoveReadOnly(_tempDir); + Directory.Delete(_tempDir, true); + } + } + catch (Exception ex) + { + Assert.Fail(ex.Message); + } + } + + // The first release has no earlier tag to increment from, so it takes the configured initial + // version exactly as given rather than incrementing it. + [Test] + [Arguments("1.0.0", "v1.0.0")] + [Arguments(null, "v0.1.0")] + public async Task FirstRelease_TakesTheInitialVersionAsIs(string? initialVersion, string expectedTag) + { + await SetUpRepository(initialVersion); + await AddChangeFile(IncrementType.Minor, "The first feature"); + + var (exitCode, _, error) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(error).DoesNotContain("at AutoVer."); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains(expectedTag); + } + + // Nothing carries the version, so a release writes no file and produces no version-bump commit. + [Test] + public async Task Release_WritesNoFileAndCreatesNoCommit() + { + await SetUpRepository("1.0.0"); + await AddChangeFile(IncrementType.Minor, "The first feature"); + + var commitsBefore = GitUtilities.GetCommitCount(_tempDir); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + await Assert.That(GitUtilities.GetCommitCount(_tempDir)).IsEqualTo(commitsBefore); + await Assert.That(GitUtilities.HasUncommittedChanges(_tempDir)).IsFalse(); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains("v1.0.0"); + } + + // The second release reads the version back out of the first release's tag. + [Test] + public async Task SubsequentRelease_IncrementsTheVersionReadBackFromTheTag() + { + await SetUpRepository("1.0.0"); + + await AddChangeFile(IncrementType.Minor, "The first feature"); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["changelog", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + await AddChangeFile(IncrementType.Patch, "A later fix"); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + var tags = GitUtilities.GetAllTags(_tempDir); + await Assert.That(tags).Contains("v1.0.0"); + await Assert.That(tags).Contains("v1.0.1"); + } + + // One tag carries one version for the whole repository, so the largest increment asked for wins. + [Test] + public async Task IncrementType_IsTheLargestAnyProjectAskedFor() + { + await SetUpRepository("1.0.0", "docs", "templates"); + + // A change file for one of the named projects is enough to make a release. + await AddChangeFile(IncrementType.Minor, "The first feature", "docs"); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains("v1.0.0"); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["changelog", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + // A Patch on one project and a Major on the other: the release is a Major. + await AddChangeFile(IncrementType.Patch, "A small docs fix", "docs"); + await AddChangeFile(IncrementType.Major, "A breaking template change", "templates"); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains("v2.0.0"); + } + + [Test] + public async Task Changelog_LabelsTheReleaseWithTheVersionFromTheTag() + { + await SetUpRepository("1.0.0"); + await AddChangeFile(IncrementType.Minor, "The first feature"); + + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["changelog", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + var changelog = await IOUtilities.GetChangelog(_tempDir); + await Assert.That(changelog).Contains("Release 1.0.0"); + await Assert.That(changelog).Contains("### ci (1.0.0)"); + await Assert.That(changelog).Contains("The first feature"); + + var (tagExitCode, tagName, _) = await AutoVerUtilities.RunCapturingOutput( + ["changelog", "--project-path", _tempDir, "--tag-name"]); + await Assert.That(tagExitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(tagName.Trim()).IsEqualTo("v1.0.0"); + } + + [Test] + public async Task Current_PrintsTheVersionFromTheTagAndFailsBeforeAnyRelease() + { + await SetUpRepository("1.0.0"); + + var (beforeExitCode, _, beforeError) = await AutoVerUtilities.RunCapturingOutput( + ["version", "--project-path", _tempDir, "--current"]); + await Assert.That(beforeExitCode).IsEqualTo(CommandReturnCodes.UserError); + await Assert.That(beforeError).Contains("autover version"); + await Assert.That(beforeError).DoesNotContain("at AutoVer."); + + await AddChangeFile(IncrementType.Minor, "The first feature"); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + var (afterExitCode, output, _) = await AutoVerUtilities.RunCapturingOutput( + ["version", "--project-path", _tempDir, "--current"]); + await Assert.That(afterExitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(output.Trim()).IsEqualTo("1.0.0"); + } + + // "Nothing was asked for" has to mean no release whether or not the repository has released + // before. Deciding it after the first-release branch let a first release be cut from an empty set + // of changes while every release after it was correctly declined. + [Test] + public async Task NoChangeFiles_ReleasesNothing_OnTheFirstReleaseToo() + { + await SetUpRepository("1.0.0"); + + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).IsEmpty(); + + // And with something to release, it releases. + await AddChangeFile(IncrementType.Minor, "The first feature"); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains("v1.0.0"); + } + + // The tag is the only place a tag-sourced version lives, so a label the format can't render is + // lost outright rather than merely missing from the tag. + [Test] + public async Task PrereleaseLabelWithNoPlaceholderToCarryIt_FailsCleanlyWithUserError() => + await AssertConfigurationRejected( + @"""Projects"": [ { ""Name"": ""ci"", ""PrereleaseLabel"": ""beta"" } ], + ""VersionFromTag"": true, + ""TagFormat"": ""v{major}.{minor}.{patch}""", + "PrereleaseLabel"); + + // The label applies to the first release as well - otherwise configuring one would be ignored on + // the first release and honoured on every release after it. + [Test] + public async Task PrereleaseLabel_AppliesToTheFirstReleaseAndAfter() + { + await IOUtilities.AddAutoVerFile(_tempDir, + @"{ + ""Projects"": [ { ""Name"": ""ci"", ""PrereleaseLabel"": ""beta"" } ], + ""UseCommitsForChangelog"": false, + ""ChangeFilesDetermineIncrementType"": true, + ""VersionFromTag"": true, + ""InitialVersion"": ""1.0.0"", + ""TagFormat"": ""v{major}.{minor}.{patch}[-{prerelease}]"" +}"); + await File.WriteAllTextAsync(Path.Combine(_tempDir, "README.md"), "# templates"); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Initial Commit"); + + await AddChangeFile(IncrementType.Minor, "The first feature"); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["changelog", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + await AddChangeFile(IncrementType.Patch, "A later fix"); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + var tags = GitUtilities.GetAllTags(_tempDir); + await Assert.That(tags).Contains("v1.0.0-beta"); + await Assert.That(tags).Contains("v1.0.1-beta"); + } + + [Test] + public async Task UseVersion_OverridesTheTagDerivedVersion() + { + await SetUpRepository("1.0.0"); + + var exitCode = await AutoVerUtilities.InitializeApp() + .Run(["version", "--project-path", _tempDir, "--use-version", "3.2.1"]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains("v3.2.1"); + } + + // The release-branch workflow a shared CI templates repository uses. Nothing carries a version, + // so `version` writes no file and creates no commit - which means the tag lands on the branch + // point and the changelog commit that follows is the release's only content. The pipeline moves + // the tag onto that commit afterwards; without it, merging the tag would deliver no changelog. + [Test] + public async Task ReleaseBranchFlow_TagCoversTheChangelogAndReachesTheTargetBranch() + { + await SetUpRepository("1.0.0"); + var defaultBranch = GitUtilities.GetCurrentBranch(_tempDir); + await AddChangeFile(IncrementType.Minor, "The first templates"); + + var branchPoint = GitUtilities.GetCommitCount(_tempDir); + GitUtilities.CreateAndCheckoutBranch(_tempDir, "releases/next-release"); + + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + // No file to write, so the release itself adds no commit. + await Assert.That(GitUtilities.GetCommitCount(_tempDir)).IsEqualTo(branchPoint); + + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["changelog", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + var (tagExitCode, tagName, _) = await AutoVerUtilities.RunCapturingOutput( + ["changelog", "--project-path", _tempDir, "--tag-name"]); + await Assert.That(tagExitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(tagName.Trim()).IsEqualTo("v1.0.0"); + + GitUtilities.MoveTagToHead(_tempDir, tagName.Trim()); + + // Merging the tag is what publishes the release, so the changelog has to travel with it. + GitUtilities.CheckoutBranch(_tempDir, defaultBranch); + GitUtilities.MergeNoFastForward(_tempDir, "releases/next-release"); + + var changelog = await IOUtilities.GetChangelog(_tempDir); + await Assert.That(changelog).Contains("Release 1.0.0"); + await Assert.That(changelog).Contains("The first templates"); + + // And the release is still correctly identified from the merge commit. + var (mergedExitCode, mergedTag, _) = await AutoVerUtilities.RunCapturingOutput( + ["changelog", "--project-path", _tempDir, "--tag-name"]); + await Assert.That(mergedExitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(mergedTag.Trim()).IsEqualTo("v1.0.0"); + } + + // The tag is the only place the version lives, so a date-based tag has nothing to read back. + [Test] + public async Task DateBasedTagFormat_FailsCleanlyWithUserError() => + await AssertConfigurationRejected( + @"""Projects"": [ { ""Name"": ""ci"" } ], + ""VersionFromTag"": true, + ""TagFormat"": ""release_{date}""", + "TagFormat"); + + // One tag carries one version, so a file-backed project alongside a tag-sourced one is ambiguous. + [Test] + public async Task ProjectWithAPath_FailsCleanlyWithUserError() + { + await IOUtilities.CreateDockerfile(_tempDir); + await IOUtilities.SetDockerfileVersion(Path.Combine(_tempDir, "Dockerfile"), "1.0.0"); + + await AssertConfigurationRejected( + @"""Projects"": [ { ""Name"": ""ci"", ""Path"": ""Dockerfile"" } ], + ""VersionFromTag"": true, + ""TagFormat"": ""v{major}.{minor}.{patch}""", + "Path"); + } + + // A change file needs a project name to attach to, and the changelog needs one to label. Without + // one, the message has to name that - not complain about missing .csproj/.nuspec/Dockerfile files. + [Test] + public async Task NoProjectsListed_FailsCleanlyNamingTheRealProblem() + { + await AssertConfigurationRejected( + @"""Projects"": [], + ""VersionFromTag"": true, + ""TagFormat"": ""v{major}.{minor}.{patch}""", + "no projects"); + + var (_, _, error) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir]); + await Assert.That(error).DoesNotContain(".csproj"); + } + + [Test] + public async Task UnparseableInitialVersion_FailsCleanlyWithUserError() => + await AssertConfigurationRejected( + @"""Projects"": [ { ""Name"": ""ci"" } ], + ""VersionFromTag"": true, + ""InitialVersion"": ""one"", + ""TagFormat"": ""v{major}.{minor}.{patch}""", + "three part version"); + + private async Task AssertConfigurationRejected(string settings, string expectedInError) + { + await IOUtilities.AddAutoVerFile(_tempDir, + $@"{{ + {settings}, + ""UseCommitsForChangelog"": false, + ""ChangeFilesDetermineIncrementType"": true +}}"); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Initial Commit"); + + var (exitCode, _, error) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.UserError); + await Assert.That(error).Contains(expectedInError); + await Assert.That(error).DoesNotContain("at AutoVer."); + } + + private async Task SetUpRepository(string? initialVersion, params string[] projectNames) + { + var names = projectNames.Length > 0 ? projectNames : ["ci"]; + var projects = string.Join(", ", names.Select(name => $@"{{ ""Name"": ""{name}"" }}")); + var initial = string.IsNullOrEmpty(initialVersion) + ? "" + : $@" + ""InitialVersion"": ""{initialVersion}"","; + + await IOUtilities.AddAutoVerFile(_tempDir, + $@"{{ + ""Projects"": [ {projects} ], + ""UseCommitsForChangelog"": false, + ""ChangeFilesDetermineIncrementType"": true, + ""VersionFromTag"": true,{initial} + ""TagFormat"": ""{SemverTagFormat}"" +}}"); + + // A README stands in for whatever the repository actually holds - deliberately nothing that + // any project file handler would recognise. + await File.WriteAllTextAsync(Path.Combine(_tempDir, "README.md"), "# templates"); + + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Initial Commit"); + } + + private async Task AddChangeFile(IncrementType incrementType, string message, string projectName = "ci") + { + var changeFilePath = await IOUtilities.AddChangeFile(projectName, incrementType, message, _tempDir); + GitUtilities.StageChanges(_tempDir, changeFilePath); + GitUtilities.CommitChanges(_tempDir, message); + } +} diff --git a/test/AutoVer.UnitTests/ProjectFiles/CsprojNuspecFileHandlerTest.cs b/test/AutoVer.UnitTests/ProjectFiles/CsprojNuspecFileHandlerTest.cs index 4bfebc9..bbd0f0c 100644 --- a/test/AutoVer.UnitTests/ProjectFiles/CsprojNuspecFileHandlerTest.cs +++ b/test/AutoVer.UnitTests/ProjectFiles/CsprojNuspecFileHandlerTest.cs @@ -182,15 +182,29 @@ await Assert.That(() => handler.UpdateVersion(definition, IncrementType.Patch, o } [Test] - public async Task UpdateVersion_NoVersionTag_ThrowsNoVersionTagException() + // A project with no Version element is seeded rather than rejected: the element is created in an + // unconditioned PropertyGroup, taking the given version as-is. + public async Task UpdateVersion_NoVersionTag_CreatesTheElementWithTheGivenVersion() { var handler = CreateHandler(); var projectPath = Path.Combine(_tempDir, "Project1.csproj"); await File.WriteAllTextAsync(projectPath, CsprojWithoutVersion); var definition = handler.Load(projectPath, await File.ReadAllTextAsync(projectPath)); - await Assert.That(() => handler.UpdateVersion(definition, IncrementType.Patch)) - .Throws(); + handler.UpdateVersion(definition, IncrementType.Patch, overrideVersion: "1.0.0"); + + await Assert.That(definition.Version).IsEqualTo("1.0.0"); + + // The created element is what a reload reads back, and it sits inside a PropertyGroup. + var written = await File.ReadAllTextAsync(projectPath); + await Assert.That(written).Contains("1.0.0"); + await Assert.That(handler.Load(projectPath, written).Version).IsEqualTo("1.0.0"); + + // Indentation is made of real text nodes under PreserveWhitespace, so the element has to be + // placed before the closing tag's own whitespace - appending after it lines the new element + // up with nothing and leaves on the same line. + await Assert.That(written.Replace("\r\n", "\n")).Contains( + " net8.0\n 1.0.0\n "); } // The error paths above were only ever exercised against .csproj; GetVersionTagName has a @@ -208,14 +222,25 @@ await Assert.That(() => handler.UpdateVersion(definition, IncrementType.Patch, o } [Test] - public async Task UpdateVersion_Nuspec_NoVersionTag_ThrowsNoVersionTagException() + // The nuspec's version belongs in its metadata element, not wherever a csproj would put it. + public async Task UpdateVersion_Nuspec_NoVersionTag_CreatesTheElementInMetadata() { var handler = CreateHandler(); var projectPath = Path.Combine(_tempDir, "Package.nuspec"); await File.WriteAllTextAsync(projectPath, NuspecWithoutVersion); var definition = handler.Load(projectPath, await File.ReadAllTextAsync(projectPath)); - await Assert.That(() => handler.UpdateVersion(definition, IncrementType.Patch)) - .Throws(); + handler.UpdateVersion(definition, IncrementType.Patch, overrideVersion: "1.0.0"); + + await Assert.That(definition.Version).IsEqualTo("1.0.0"); + + var written = await File.ReadAllTextAsync(projectPath); + await Assert.That(written).Contains("1.0.0"); + await Assert.That(handler.Load(projectPath, written).Version).IsEqualTo("1.0.0"); + + // Placed inside metadata rather than appended after it. + var document = new System.Xml.XmlDocument(); + document.LoadXml(written); + await Assert.That(document.GetElementsByTagName("version")[0]!.ParentNode!.Name).IsEqualTo("metadata"); } } diff --git a/test/AutoVer.UnitTests/ProjectFiles/DockerfileFileHandlerTest.cs b/test/AutoVer.UnitTests/ProjectFiles/DockerfileFileHandlerTest.cs index c146da7..5607785 100644 --- a/test/AutoVer.UnitTests/ProjectFiles/DockerfileFileHandlerTest.cs +++ b/test/AutoVer.UnitTests/ProjectFiles/DockerfileFileHandlerTest.cs @@ -1,3 +1,4 @@ +using AutoVer.Constants; using AutoVer.Exceptions; using AutoVer.Models; using AutoVer.Services; @@ -197,15 +198,45 @@ await Assert.That(() => handler.UpdateVersion(definition, IncrementType.Patch, o } [Test] - public async Task UpdateVersion_NoLabel_ThrowsNoVersionTagException() + // A Dockerfile with no version LABEL is seeded rather than rejected: the label is appended at + // the end of the file, which puts it in the final build stage - the stage whose labels the built + // image carries - and it takes the given version as-is rather than incrementing from nothing. + public async Task UpdateVersion_NoLabel_AppendsTheLabelWithTheGivenVersion() { var handler = CreateHandler(); var dockerfilePath = Path.Combine(_tempDir, "Dockerfile"); await File.WriteAllTextAsync(dockerfilePath, DockerfileWithoutLabel); var definition = handler.Load(dockerfilePath, await File.ReadAllTextAsync(dockerfilePath)); - await Assert.That(() => handler.UpdateVersion(definition, IncrementType.Patch)) - .Throws(); + handler.UpdateVersion(definition, IncrementType.Patch, overrideVersion: "1.0.0"); + + await Assert.That(definition.Version).IsEqualTo("1.0.0"); + + var written = await File.ReadAllTextAsync(dockerfilePath); + await Assert.That(written).EndsWith($"LABEL {ProjectConstants.DockerImageVersionLabel}=\"1.0.0\""); + // Everything that was already there is left untouched. + await Assert.That(written).StartsWith("FROM alpine:3.20"); + await Assert.That(written).Contains("CMD [\"/app/run.sh\"]"); + + // And the appended label is what a reload reads back as the version. + var reloaded = handler.Load(dockerfilePath, written); + await Assert.That(reloaded.Version).IsEqualTo("1.0.0"); + } + + // A file ending in a newline must keep it, and must not gain a blank line before the label. + [Test] + public async Task UpdateVersion_NoLabel_PreservesATrailingNewline() + { + var handler = CreateHandler(); + var dockerfilePath = Path.Combine(_tempDir, "Dockerfile"); + await File.WriteAllTextAsync(dockerfilePath, DockerfileWithoutLabel + "\n"); + var definition = handler.Load(dockerfilePath, await File.ReadAllTextAsync(dockerfilePath)); + + handler.UpdateVersion(definition, IncrementType.Patch, overrideVersion: "1.0.0"); + + var written = await File.ReadAllTextAsync(dockerfilePath); + await Assert.That(written).EndsWith($"LABEL {ProjectConstants.DockerImageVersionLabel}=\"1.0.0\"\n"); + await Assert.That(written).DoesNotContain("\n\nLABEL"); } // A comment mentioning the version label text must not be treated as the version