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
16 changes: 16 additions & 0 deletions .autover/changes/8b6428ea-f0a2-45cd-af3e-d765bf0d69bb.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"Projects": [
{
"Name": "AutoVer",
"Type": "Minor",
"ChangelogMessages": [
"Version Python projects: a pyproject.toml listed in autover.json is versioned through its [project] version (PEP 621), rewriting only that value so quoting, spacing, comments and line endings are kept as they were. A project without a version is seeded after its name, like the other project types",
"A pyproject.toml is never auto-discovered, because many repositories keep one purely for tool settings; discovering those would start versioning a lint config in repositories that rely on discovery today",
"Refused with an explanation rather than guessed at: a version listed in [project] dynamic (computed by the build backend), a [tool.poetry]-only project, a non-string version, and a version AutoVer cannot increment (a normalized spelling such as 1.2.3b1)",
"A prerelease label must be a Python pre-release (PEP 440): alpha, beta, rc (and their short forms), pre/preview or dev, optionally numbered. Anything else fails the release before any project file is written, rather than producing a package pip refuses to install (hotfix) or a post-release that pip installs ahead of the real version (post.1)",
"A version a pyproject.toml cannot take (not a valid Python version) now fails the release before any project file is written, so a repository mixing .csproj, Dockerfile and pyproject.toml projects is not left with the others bumped and staged",
"A project file that fails to load now reports why: the configuration-loading error includes the underlying cause (e.g. a missing project file, a pyproject.toml that cannot be versioned, or the JSON path of an invalid setting) instead of only naming autover.json"
]
}
]
}
37 changes: 33 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ Requires the .NET 10 SDK or later. The installed command is `autover`.

* Versions one or many projects in a single git repository, with independent or
shared version numbers
* Versions `.csproj`, `.nuspec` and `Dockerfile` projects - the latter through the
`org.opencontainers.image.version` label
* Versions `.csproj`, `.nuspec`, `Dockerfile` and Python `pyproject.toml` projects -
a Dockerfile through the `org.opencontainers.image.version` label, a Python project
through `[project] version`
* Applies Patch, Minor or Major increments, chosen per release or per change
* Commits the version bump and tags the release
* Generates a `CHANGELOG.md` from conventional commit messages or from
Expand Down Expand Up @@ -155,6 +156,33 @@ Each entry in `Projects` takes:
| `IncrementType` | An increment specific to this project. |
| `PrereleaseLabel` | A prerelease label to apply, e.g. `beta.1`. |

### Python projects

A `pyproject.toml` is versioned through the `version` key of its `[project]` table
([PEP 621](https://peps.python.org/pep-0621/)). Only that value is rewritten: quoting,
spacing, comments, line endings and everything else in the file stay as they were (a
UTF-8 byte-order mark is not kept).

```json
{ "Projects": [ { "Name": "my-package", "Path": "pyproject.toml" } ] }
```

* **It must be listed in `Projects`** - a `pyproject.toml` is never auto-discovered.
Plenty of repositories keep one purely for tool settings (black, ruff, pytest), and
discovering those would start versioning a lint config.
* **The version must be a plain, single-line string in `[project]`**, in AutoVer's
`MAJOR.MINOR.PATCH[-label]` form. A project that lists `"version"` in `dynamic` (its
build backend computes the version, e.g. from git tags) is refused, as is Poetry's
pre-2.0 `[tool.poetry]` version and a normalized spelling such as `1.2.3b1` - write
that one as `1.2.3-b1`, which is the same version.
* **Prerelease labels must be Python pre-releases**
([PEP 440](https://peps.python.org/pep-0440/)): `alpha`/`a`, `beta`/`b`,
`rc`/`c`/`pre`/`preview` or `dev`, optionally numbered. `beta.1` writes `1.2.3-beta.1`,
which pip reads as `1.2.3b1`. Anything else fails the release before any file is
written: `hotfix` would make an uninstallable package, and `post.1` or `1` would be a
*post*-release, which pip ranks above `1.2.3` and installs by default. Note that pip
orders `dev` before `alpha`, where AutoVer's own ordering puts it after.

## Tag and release name formats

By default AutoVer tags a release by date - `release_2026-09-02`, or
Expand Down Expand Up @@ -258,8 +286,9 @@ release and cover the wrong commit range. If no release tag is reachable at all

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
`PropertyGroup`, a `<version>` in a nuspec's `metadata`, an
`org.opencontainers.image.version` LABEL appended to a Dockerfile, or a `version` key
after `name` in a pyproject.toml's `[project]` table - 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.
Expand Down
39 changes: 30 additions & 9 deletions src/AutoVer/Commands/VersionCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ public async Task ExecuteAsync(
}

var projectsIncremented = false;
var plannedUpdates = new List<PlannedUpdate>();
var containersToStage = new List<ProjectContainer>();
foreach (var availableProject in userConfiguration.Projects)
{
if (!availableProject.IncrementType.Equals(IncrementType.None))
Expand All @@ -80,11 +82,11 @@ public async Task ExecuteAsync(
incrementType);
foreach (var project in availableProject.Projects)
{
projectHandler.UpdateVersion(
plannedUpdates.Add(new PlannedUpdate(
project.ProjectDefinition,
projectIncrementType,
availableProject.PrereleaseLabel,
optionUseVersion ?? repositorySeedVersion ?? maxNextVersion?.ToString() ?? localMaxVersion?.ToString());
optionUseVersion ?? repositorySeedVersion ?? maxNextVersion?.ToString() ?? localMaxVersion?.ToString()));
}
}
else
Expand All @@ -102,11 +104,11 @@ public async Task ExecuteAsync(
incrementType);
foreach (var project in availableProject.Projects)
{
projectHandler.UpdateVersion(
plannedUpdates.Add(new PlannedUpdate(
project.ProjectDefinition,
projectIncrementType,
availableProject.PrereleaseLabel,
optionUseVersion ?? SeedVersionFor(availableProject, userConfiguration) ?? localMaxVersion?.ToString());
optionUseVersion ?? SeedVersionFor(availableProject, userConfiguration) ?? localMaxVersion?.ToString()));
}
}
else
Expand All @@ -120,19 +122,32 @@ public async Task ExecuteAsync(
continue;
foreach (var project in availableProject.Projects)
{
projectHandler.UpdateVersion(
plannedUpdates.Add(new PlannedUpdate(
project.ProjectDefinition,
projectIncrementType,
availableProject.PrereleaseLabel,
optionUseVersion ?? SeedVersionFor(availableProject, userConfiguration) ?? localMaxVersion?.ToString());
optionUseVersion ?? SeedVersionFor(availableProject, userConfiguration) ?? localMaxVersion?.ToString()));
}
}
}

foreach (var project in availableProject.Projects)
{
containersToStage.Add(availableProject);
}

// Every project is checked before any is written, so a version a file type refuses up front
// (see IProjectFileHandler.ValidateVersion - today a pyproject.toml given a non-Python
// version) fails the release before any file changes. A failure only discovered while
// writing (e.g. a nuspec with no <metadata>) can still leave earlier files bumped, as before.
foreach (var update in plannedUpdates)
projectHandler.ValidateVersion(update.Definition, update.IncrementType, update.PrereleaseLabel, update.OverrideVersion);

foreach (var update in plannedUpdates)
projectHandler.UpdateVersion(update.Definition, update.IncrementType, update.PrereleaseLabel, update.OverrideVersion);

foreach (var container in containersToStage)
{
foreach (var project in container.Projects)
gitHandler.StageChanges(userConfiguration, project.Path);
}
}

// When done, reset the config file if the user had one
Expand Down Expand Up @@ -167,6 +182,12 @@ public async Task ExecuteAsync(
}
}

private sealed record PlannedUpdate(
ProjectDefinition Definition,
IncrementType IncrementType,
string? PrereleaseLabel,
string? OverrideVersion);

/// <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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public static void AddCustomServices(this IServiceCollection serviceCollection,
serviceCollection.TryAdd(new ServiceDescriptor(typeof(IVersionHandler), typeof(VersionHandler), lifetime));
serviceCollection.TryAddEnumerable(new ServiceDescriptor(typeof(IProjectFileHandler), typeof(CsprojNuspecFileHandler), lifetime));
serviceCollection.TryAddEnumerable(new ServiceDescriptor(typeof(IProjectFileHandler), typeof(DockerfileFileHandler), lifetime));
serviceCollection.TryAddEnumerable(new ServiceDescriptor(typeof(IProjectFileHandler), typeof(PyprojectFileHandler), lifetime));
serviceCollection.TryAdd(new ServiceDescriptor(typeof(IProjectFileHandlerResolver), typeof(ProjectFileHandlerResolver), lifetime));

serviceCollection.AddSingleton<App>();
Expand Down
4 changes: 2 additions & 2 deletions src/AutoVer/Models/UserConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ public void OnDeserialized()
{
var normalizedPath = path.Replace('\\', _pathManager.DirectorySeparatorChar).Replace('/', _pathManager.DirectorySeparatorChar);
if (!_fileManager.Exists(normalizedPath))
throw new Exception($"Failed to find a valid .csproj, .nuspec, or Dockerfile file at path {normalizedPath}");
throw new Exception($"Failed to find a valid .csproj, .nuspec, Dockerfile, or pyproject.toml file at path {normalizedPath}");

var handler = _projectFileHandlerResolver.Resolve(normalizedPath);
var projectDefinition = handler.Load(_pathManager.GetFullPath(normalizedPath), _fileManager.ReadAllText(normalizedPath));
Expand Down Expand Up @@ -188,7 +188,7 @@ public void OnDeserialized()

var normalizedPath = Path.Replace('\\', _pathManager.DirectorySeparatorChar).Replace('/', _pathManager.DirectorySeparatorChar);
if (!_fileManager.Exists(normalizedPath))
throw new Exception($"Failed to find a valid .csproj, .nuspec, or Dockerfile file at path {normalizedPath}");
throw new Exception($"Failed to find a valid .csproj, .nuspec, Dockerfile, or pyproject.toml file at path {normalizedPath}");

var handler = _projectFileHandlerResolver.Resolve(normalizedPath);
ProjectDefinition = handler.Load(_pathManager.GetFullPath(normalizedPath), _fileManager.ReadAllText(normalizedPath));
Expand Down
20 changes: 14 additions & 6 deletions src/AutoVer/Services/ConfigurationManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,22 @@ public class ConfigurationManager(
}
catch (Exception ex)
{
throw new InvalidUserConfigurationException(
$"There was an issue loading the user configuration at '{configPath}'.",
ex);
throw new InvalidUserConfigurationException(LoadFailureMessage(configPath, ex), ex);
}
}

// Saying only *where* loading failed hides the part the user can act on - e.g. a project file
// AutoVer cannot version, or a JSON syntax error - so the cause is included in the message.
private static string LoadFailureMessage(string configPath, Exception ex)
{
var cause = ex;
// Stop at a JsonException too: it carries the JSON path and line the user needs, while its
// inner exception only says which token type was wrong.
while (cause is not (AutoVerException or JsonException) && cause.InnerException is not null)
cause = cause.InnerException;
return $"There was an issue loading the user configuration at '{configPath}': {cause.Message}";
}

public async Task<UserConfiguration> RetrieveUserConfiguration(string? projectPath, IncrementType incrementType, string? tagName = null)
{
if (string.IsNullOrEmpty(projectPath))
Expand Down Expand Up @@ -149,9 +159,7 @@ public async Task<UserConfiguration> RetrieveUserConfiguration(string? projectPa
}
catch (Exception ex)
{
throw new InvalidUserConfigurationException(
$"There was an issue loading the user configuration at '{configPath}'.",
ex);
throw new InvalidUserConfigurationException(LoadFailureMessage(configPath, ex), ex);
}
}

Expand Down
1 change: 1 addition & 0 deletions src/AutoVer/Services/IProjectHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ public interface IProjectHandler
Task<List<ProjectDefinition>> GetAvailableProjects(string? projectPath);
Task<ProjectDefinition> GetProjectDefinition(string projectPath);
void UpdateVersion(ProjectDefinition projectDefinition, IncrementType incrementType, string? prereleaseLabel = null, string? overrideVersion = null);
void ValidateVersion(ProjectDefinition projectDefinition, IncrementType incrementType, string? prereleaseLabel = null, string? overrideVersion = null);
}
10 changes: 9 additions & 1 deletion src/AutoVer/Services/ProjectFiles/IProjectFileHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
namespace AutoVer.Services.ProjectFiles;

/// <summary>
/// Handles reading and writing the version of one project file type (e.g. .csproj, .nuspec, Dockerfile).
/// Handles reading and writing the version of one project file type (e.g. .csproj, .nuspec, Dockerfile, pyproject.toml).
/// </summary>
public interface IProjectFileHandler
{
Expand All @@ -26,4 +26,12 @@ public interface IProjectFileHandler
ProjectDefinition Load(string projectPath, string rawContent);

void UpdateVersion(ProjectDefinition projectDefinition, IncrementType incrementType, string? prereleaseLabel = null, string? overrideVersion = null);

/// <summary>
/// Throws if <see cref="UpdateVersion"/> would refuse these arguments, without writing anything.
/// Called for every project before any file is written, so a release that one project file
/// cannot take fails before the others have been bumped and staged. Most file types accept any
/// version AutoVer produces, hence the no-op default.
/// </summary>
void ValidateVersion(ProjectDefinition projectDefinition, IncrementType incrementType, string? prereleaseLabel = null, string? overrideVersion = null) { }
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public class ProjectFileHandlerResolver(
public IProjectFileHandler Resolve(string projectPath)
{
if (!TryResolve(projectPath, out var handler))
throw new InvalidProjectException($"Invalid project path {projectPath}. The project path must point to a .csproj, .nuspec, or Dockerfile file.");
throw new InvalidProjectException($"Invalid project path {projectPath}. The project path must point to a .csproj, .nuspec, Dockerfile, or pyproject.toml file.");

return handler!;
}
Expand Down
Loading
Loading