From 0b098d1ac6a4cb3cd7b05a198b9696eac197432a Mon Sep 17 00:00:00 2001 From: Philip Hartlieb Date: Mon, 9 Nov 2026 15:54:25 -0500 Subject: [PATCH 1/2] Read the release version from a VERSION file in repositories without a package manifest release-check and stable-guard read the version only from pyproject.toml (or through it) or Cargo.toml, so a Go or Docker-only repository (bbot-io-developer-portal) had no way to release. A root VERSION file now supplies it when there is no manifest version. Where both exist they must agree, as chart versions must, so the file cannot drift. Tests cover a VERSION-only repository, a prerelease in VERSION, a mismatch, a match, and the error that names the missing source. --- README.md | 2 +- scripts/manifest.py | 22 +++++++++++++++++----- tests/test_manifest.py | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8d6e89e..94b4e7b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ same release controls as the projects it publishes. | `python-tests.yml`, `python-versions.yml`, `rust-tests.yml` | Test matrices | | `dev-security.yml` | Dependency review and workflow lint (`actionlint`, `zizmor`), the security checks `dev` requires | | `stable-guard.yml` | Checks a pull request into `stable` is release-ready: a MAJOR.MINOR.PATCH version, matching chart versions, no git dependencies | -| `release-check.yml` | Runs on `stable` only: reads the version, refuses a partly published release, checks merged-PR provenance and that the `release` environment is protected | +| `release-check.yml` | Runs on `stable` only: reads the version, refuses a partly published release, checks merged-PR provenance and that the `release` environment is protected. The version comes from the package manifest (`pyproject.toml`, `Cargo.toml`), or from a `VERSION` file at the repository root in a repository without one, such as a Go or Docker-only repository; where both exist they must agree | | `maturin-wheels.yml`, `crates.yml`, `docker.yml` | Build and publish artifacts | | `docker-dev.yml` | `dev` channel images on GHCR (not releases) | | `publish.yml` | Creates the tag and GitHub release with the release App, after everything is published | diff --git a/scripts/manifest.py b/scripts/manifest.py index bc868c7..f7fbc94 100644 --- a/scripts/manifest.py +++ b/scripts/manifest.py @@ -82,7 +82,7 @@ def python_versions(root): return versions -def version(root): +def manifest_version(root): pyproject = load(root / "pyproject.toml") project = pyproject.get("project", {}) if "version" in project: @@ -95,10 +95,22 @@ def version(root): if "version" in tool.get("poetry", {}): return tool["poetry"]["version"] cargo = root / tool.get("maturin", {}).get("manifest-path", "Cargo.toml") - found = cargo_version(cargo) - if found is None: - sys.exit(f"no static version in {root}") - return found + return cargo_version(cargo) + + +def version(root): + """The release version: the package manifest's, or a VERSION file's for a repository without one (Docker or Go). + + Where both exist they must agree, so a VERSION file can't drift from the manifest it duplicates. + """ + declared = manifest_version(root) + path = root / "VERSION" + from_file = path.read_text().strip() if path.is_file() else None + if declared is None and from_file is None: + sys.exit(f"no static version in {root}: add a VERSION file or a version to the package manifest") + if declared is not None and from_file is not None and declared != from_file: + sys.exit(f"VERSION {from_file} does not match manifest version {declared}") + return declared if declared is not None else from_file def emit(**outputs): diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 4f6e348..44e70cf 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -164,3 +164,44 @@ def test_release_refuses_git_crates(repo, cargo): def test_release_allows_registry_dependencies_with_markers(repo): root = repo('[project]\nname = "x"\nversion = "1.0.0"\ndependencies = ["radixtarget>=3; python_version >= \'3.11\'", "y[extra]~=1.0"]\n') assert run(root, "release").returncode == 0 + + +def bare(tmp_path, files): + for name, body in files.items(): + (tmp_path / name).write_text(body) + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + subprocess.run(["git", "add", "."], cwd=tmp_path, check=True) + return tmp_path + + +def test_release_reads_a_version_file_without_a_package_manifest(tmp_path): + root = bare(tmp_path, {"VERSION": "1.7.2\n", "go.mod": "module example.com/x\n"}) + assert outputs(run(root, "release")) == { + "version": "1.7.2", + "tag": "v1.7.2", + "major": "1", + "minor": "7", + "pypi_name": "", + "crate_name": "", + } + + +def test_release_refuses_a_prerelease_version_file(tmp_path): + result = run(bare(tmp_path, {"VERSION": "1.7.2-rc1\n"}), "release") + assert result.returncode != 0 and "MAJOR.MINOR.PATCH" in result.stderr + + +def test_release_refuses_a_version_file_that_disagrees_with_the_manifest(repo): + result = run(repo('[project]\nname = "x"\nversion = "1.2.3"\n', {"VERSION": "1.2.4\n"}), "release") + assert result.returncode != 0 and "does not match manifest version 1.2.3" in result.stderr + + +def test_release_accepts_a_version_file_that_matches_the_manifest(repo): + root = repo('[project]\nname = "x"\nversion = "1.2.3"\n', {"VERSION": "1.2.3\n"}) + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + assert outputs(run(root, "release"))["version"] == "1.2.3" + + +def test_release_names_the_missing_version_source(tmp_path): + result = run(bare(tmp_path, {"go.mod": "module example.com/x\n"}), "release") + assert result.returncode != 0 and "add a VERSION file" in result.stderr From 60ad464d45aecfdbb62b28707ca9a648af8c11ce Mon Sep 17 00:00:00 2001 From: Philip Hartlieb Date: Mon, 9 Nov 2026 15:54:26 -0500 Subject: [PATCH 2/2] 1.2.0 Minor: a VERSION file is a new way to declare the release version; nothing changes for existing callers. --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e91c2f0..47597af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "release-workflows" -version = "1.1.0" +version = "1.2.0" requires-python = ">=3.11" dependencies = ["packaging", "pyyaml"] diff --git a/uv.lock b/uv.lock index f5ad840..5c1c6e0 100644 --- a/uv.lock +++ b/uv.lock @@ -126,7 +126,7 @@ wheels = [ [[package]] name = "release-workflows" -version = "1.1.0" +version = "1.2.0" source = { virtual = "." } dependencies = [ { name = "packaging" },