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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ dist/
sandbox/**/bin/
sandbox/**/obj/
sandbox/**/node_modules/
sandbox/**/.venv/
sandbox/**/__pycache__/
32 changes: 9 additions & 23 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,6 @@ agreed, at which point it is ranked by value.

Each says what would settle it. Nothing moves up until something does.

### Python projects

`pyproject.toml` holds `[project].version`, which is the same in-place TOML
edit vump already performs. What is undecided is `uv`, which is the tool that
would be used here: it keeps a `uv.lock` recording the project's own version,
so the lock question arrives with it.

Settling it is an experiment, not a discussion, and the same one that settled
npm and Cargo: put a project in `sandbox/py/`, bump the version by hand, run
`uv lock`, and diff. If the only change is the project's own version, the lock
is trackable on exactly the terms `Cargo.lock` and `package-lock.json` are —
computable with no network and no knowledge of the dependency graph. If `uv`
rewrites more than that, it is not, and `pyproject.toml` is tracked alone.

Low priority by the only measure that matters here: barely any Python is
written in this repository's orbit, so it waits behind formats that are.

### npm workspaces

A Cargo workspace's shared lock is now written per member, matched by the
Expand All @@ -57,13 +40,16 @@ inference that produced the original lock-file defect.

### Declarative version-file formats

Detection is by filename across the built-in formats. `pyproject.toml`,
`*.csproj`, `gradle.properties` and others need a per-entry extraction spec — a
path for structured formats, a pattern for the rest.
Detection is by filename. Anything else — `gradle.properties`, a `*.gemspec`, a
version in a shell script — would need a per-entry extraction spec: a path for
structured formats, a pattern for the rest.

Weaker than it looked. Both formats this entry once named as motivation,
`pyproject.toml` and `*.csproj`, were added as built-ins instead, neither
costing more than a day. A spec would change the configuration schema
permanently to avoid work that keeps turning out to be small.

The reason this has not been designed: it changes the configuration schema, and
doing that well needs a real target format in hand rather than a guess at what
would be general enough.
What would settle it: a format someone needs that is not worth adding built-in.

### A Rust project in the sandbox

Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "vump"
version = "0.7.0"
version = "0.8.0-alpha.0"
edition = "2024"
rust-version = "1.85"
description = "Keep semver version numbers in sync across a repository, and verify them in CI"
Expand Down
20 changes: 14 additions & 6 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ builds. The three extensions are one language each and one format.
| `package-lock.json` | JSON | `.version`, and `.packages[""].version` |
| `Cargo.toml` | TOML | `[package].version` |
| `Cargo.lock` | TOML | the sole `[[package]]` with no `source` |
| `pyproject.toml` | TOML | `[project].version` |
| `uv.lock` | TOML | each `[[package]]` with an editable `source` |
| `VERSION` | plain text | entire file contents |

**Writes must preserve the rest of the file byte-for-byte.** Update the version
Expand Down Expand Up @@ -128,10 +130,10 @@ formats come first.
### Lock files

A lock file that records the project's own version is a version file by the
definition above, and is tracked like any other. `Cargo.lock` and
`package-lock.json` qualify: each records the version of the project it locks,
and `cargo build --locked` and `npm ci` both reject a tree where a lock and its
manifest disagree.
definition above, and is tracked like any other. `Cargo.lock`,
`package-lock.json` and `uv.lock` qualify: each records the version of the
project it locks, and `cargo build --locked`, `npm ci` and `uv sync --locked`
each reject a tree where a lock and its manifest disagree.

This does not weaken the non-goal. Resolving dependencies is a package
manager's job and vump never does it; restating a version vump has just written
Expand All @@ -148,10 +150,16 @@ In `Cargo.lock` the entries that belong to the repository are the
everything it fetched. A single-crate repository has one, and it needs no
naming.

`uv.lock` is the same shape with the marker inverted: uv records a `source`
for both, and the entries built from the tree are the ones marked `editable`.
That distinction is load-bearing rather than cosmetic — a published release of
a workspace member appears under the member's own name, and only the source
says which of the two the tree builds.

A workspace has one such entry per member, and the lock alone cannot say which
of them a project means — so the manifests declared alongside it do. Each
`Cargo.toml` names its package, and the entries a project writes are exactly
the ones its own manifests name. That covers both shapes a workspace takes:
`Cargo.toml` or `pyproject.toml` names its package, and the entries a project
writes are exactly the ones its own manifests name. That covers both shapes a workspace takes:
members held at one version declare every manifest in a single project and
move together, while independently-versioned members declare one manifest each
and touch only their own entry. Members that are not declared are not touched.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@ order, indentation, and comments elsewhere in the file survive untouched.
| `package-lock.json` | top-level `version`, and the root `packages` entry |
| `Cargo.toml` | `[package].version` |
| `Cargo.lock` | the `[[package]]` entry for this crate |
| `pyproject.toml` | `[project].version` |
| `uv.lock` | the `[[package]]` entry for this project |
| `VERSION` | the whole file |

A `version` nested under `dependencies` is never mistaken for the project's
Expand Down
10 changes: 10 additions & 0 deletions sandbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ This is not test coverage. The suite in `tests/` and the fixtures in
| [`cs/single-project`](cs/single-project) | `<Version>` in a `.csproj`, and what stays put around it |
| [`cs/multi-project`](cs/multi-project) | Two C# projects versioned independently |
| [`cs/shared-version`](cs/shared-version) | An executable and its library on one `Directory.Build.props` |
| [`py/single-project`](py/single-project) | One Python project, `pyproject.toml` and `uv.lock` together |

## Git is off, deliberately

Expand Down Expand Up @@ -90,6 +91,15 @@ vump minor --allow-nested # rewrites one file, not two
dotnet run --project App # App 1.1.0 / Lib 1.1.0
```

For Python, the lock moves with the manifest and needs no network to do it —
`uv lock` afterwards produces the same file vump already wrote:

```bash
cd sandbox/py/single-project
vump minor --allow-nested # pyproject.toml and uv.lock
uv run python -m demo # demo 1.1.0
```

## Putting it back

Experiments are meant to be thrown away:
Expand Down
9 changes: 9 additions & 0 deletions sandbox/py/single-project/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[project]
name = "demo"
version = "1.0.0"
requires-python = ">=3.11"
dependencies = ["idna>=3.6"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
6 changes: 6 additions & 0 deletions sandbox/py/single-project/src/demo/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Reads the version from the installed metadata, which is what pyproject.toml
declares — so nothing here repeats the number vump writes."""

from importlib.metadata import version

__version__ = version("demo")
3 changes: 3 additions & 0 deletions sandbox/py/single-project/src/demo/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import __version__

print(f"demo {__version__}")
23 changes: 23 additions & 0 deletions sandbox/py/single-project/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions sandbox/py/single-project/vump.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# One Python project, manifest and lock moving together.
#
# uv.lock records the project's own version in the [[package]] entry whose
# source is editable. Editing that entry is all a bump does — asking uv to
# re-lock afterwards produces the same file, so the lock never needs the
# network to be brought back in step.
#
# Git is off throughout the sandbox, and the tag pattern cannot be mistaken
# for vump's own — see ../../npm/single-project/vump.toml.

files = ["pyproject.toml", "uv.lock"]

[git]
through = "none"
tag_pattern = "sandbox-py-v{new_version}"
commit_message = "chore(sandbox): bump py/single-project to {new_version}"
2 changes: 1 addition & 1 deletion src/app/change.rs
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@ pub fn apply(
// The same pairing the read side uses: a shared workspace lock records
// every member, and this project's manifests say which entries are its own.
let packages =
crate::app::cargo_package_names(fs, root, changes.files.iter().map(|f| f.path.as_str()));
crate::app::local_package_names(fs, root, changes.files.iter().map(|f| f.path.as_str()));

let mut written = Vec::with_capacity(changes.files.len());
for file in &changes.files {
Expand Down
2 changes: 1 addition & 1 deletion src/app/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ pub fn discover(fs: &dyn FileSystem, root: &Path) -> Vec<String> {

// A lock file's entries are identified by the manifests declared with it,
// so every manifest has to be in hand before anything can be judged.
let packages = crate::app::cargo_package_names(fs, root, found.iter().map(String::as_str));
let packages = crate::app::local_package_names(fs, root, found.iter().map(String::as_str));

found.retain(|path| readable(fs, root, path, &packages));
found
Expand Down
28 changes: 18 additions & 10 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ pub fn read_project_versions(
root: &Path,
project: &Project,
) -> Result<Vec<FileVersion>, AppError> {
let packages = cargo_package_names(fs, root, project.files.iter().map(String::as_str));
let packages = local_package_names(fs, root, project.files.iter().map(String::as_str));
let mut versions = Vec::with_capacity(project.files.len());

for declared in &project.files {
Expand Down Expand Up @@ -169,21 +169,28 @@ pub fn read_project_versions(
Ok(versions)
}

/// The package names this project's Cargo manifests declare.
/// The package names this project's manifests declare.
///
/// A shared workspace lock records every member it builds, so the entries a
/// project means are the ones its own manifests name. Manifests that cannot be
/// read are skipped here and reported by the pass that reads their version.
pub(crate) fn cargo_package_names<'a>(
/// project means are the ones its own manifests name. Cargo and uv both work
/// this way and are read together, since one project can hold both. Manifests
/// that cannot be read are skipped here and reported by the pass that reads
/// their version.
pub(crate) fn local_package_names<'a>(
fs: &dyn FileSystem,
root: &Path,
declared: impl IntoIterator<Item = &'a str>,
) -> Vec<String> {
declared
.into_iter()
.filter(|path| file_name_of(path) == "Cargo.toml")
.filter_map(|path| fs.read(&resolve(root, path)).ok())
.filter_map(|contents| version_file::cargo_package_name(&contents))
.filter_map(|path| {
let read = match file_name_of(path) {
"Cargo.toml" => version_file::cargo_package_name,
"pyproject.toml" => version_file::pyproject_package_name,
_ => return None,
};
read(&fs.read(&resolve(root, path)).ok()?)
})
.collect()
}

Expand Down Expand Up @@ -253,6 +260,7 @@ fn companion_locks(declared: &str) -> Option<(Vec<String>, LockFile)> {
let (lock, format) = match name {
"Cargo.toml" => ("Cargo.lock", LockFile::Cargo),
"package.json" => ("package-lock.json", LockFile::Npm),
"pyproject.toml" => ("uv.lock", LockFile::Uv),
_ => return None,
};

Expand Down Expand Up @@ -388,8 +396,8 @@ mod tests {
#[test]
fn an_unsupported_filename_is_rejected_before_any_read() {
let fs = MemoryFileSystem::new();
let err = read_project_versions(&fs, Path::new("/repo"), &project(&["pyproject.toml"]))
.unwrap_err();
let err =
read_project_versions(&fs, Path::new("/repo"), &project(&["setup.py"])).unwrap_err();
assert!(matches!(
err,
AppError::VersionFile(VersionFileError::UnsupportedFile { .. })
Expand Down
Loading