From 72ffac7954bb1ba61148ca6b622c90b0ab13f63a Mon Sep 17 00:00:00 2001
From: spencrr <23708360+spencrr@users.noreply.github.com>
Date: Mon, 22 Jun 2026 14:01:37 -0700
Subject: [PATCH 01/11] [META]: Generate README dynamically for functional
image permalinks on PyPI release
---
README.md | 2 +-
docs/contributing/release-process.md | 4 +-
pyproject.toml | 23 ++++----
scripts/hatch_build.py | 81 ++++++++++++++++++++++++++++
uv.lock | 26 +++++++++
5 files changed, 122 insertions(+), 14 deletions(-)
create mode 100644 scripts/hatch_build.py
diff --git a/README.md b/README.md
index a1be8bea..bf894e7b 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
RAMPART
diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md
index 08c66098..c0e52749 100644
--- a/docs/contributing/release-process.md
+++ b/docs/contributing/release-process.md
@@ -46,9 +46,9 @@ version = "x.y.z"
### Update README File
The README file is published to PyPI and also needs to be updated so the links work properly. _Note: There may not be any links to update, but it is good practice to check in case our README changes._
-Replace all “main” links like “doc/index.md” with “raw” links that have the correct version number, i.e., “https://raw.githubusercontent.com/microsoft/RAMPART/releases/vx.y.z/docs/index.md”.
+Keep README image links relative when they point to files in this repository, e.g., `docs/images/RAMPART.svg`. During package builds, `scripts/hatch_build.py` generates the PyPI README metadata and rewrites those image paths to raw GitHub URLs with the release version.
-For images, update using the “raw” link, e.g., “https://raw.githubusercontent.com/microsoft/RAMPART/releases/vx.y.z/docs/images/RAMPART.png”.
+Replace any other “main” links like “doc/index.md” with “raw” links that have the correct version number, i.e., “https://raw.githubusercontent.com/microsoft/RAMPART/releases/vx.y.z/docs/index.md”.
For directories, update using the “tree” link, e.g., “https://github.com/microsoft/RAMPART/tree/releases/vx.y.z/docs/usage"
diff --git a/pyproject.toml b/pyproject.toml
index 32a59a6b..705cb256 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,12 +1,12 @@
[build-system]
-requires = ["setuptools"]
-build-backend = "setuptools.build_meta"
+requires = ["hatchling>=1.30.1"]
+build-backend = "hatchling.build"
[project]
name = "RAMPART"
version = "0.1.1.dev0"
description = "A pytest-native safety testing framework for agentic AI applications"
-readme = "README.md"
+dynamic = ["readme"]
license = "MIT"
requires-python = ">=3.11"
authors = [
@@ -43,6 +43,7 @@ onedrive = [
[dependency-groups]
dev = [
+ "hatchling>=1.30.1",
"pre-commit>=4.5.1",
"pytest-cov>=6.1.0",
"pytest-xdist[psutil]>=3.8.0",
@@ -64,14 +65,11 @@ Issues = "https://github.com/microsoft/RAMPART/issues"
[project.entry-points.pytest11]
rampart = "rampart.pytest_plugin.plugin"
-[tool.setuptools.packages.find]
-exclude = ["site*", "docs*", "tests*", "scripts*"]
+[tool.hatch.metadata.hooks.custom]
+path = "scripts/hatch_build.py"
-[tool.setuptools.package-data]
-rampart = [
- "drivers/prompts/*.yaml",
- "evaluators/prompts/*.yaml",
-]
+[tool.hatch.build.targets.wheel]
+packages = ["rampart"]
[tool.coverage.run]
source = ["rampart"]
@@ -105,6 +103,9 @@ extend-ignore = [
]
[tool.ruff.lint.per-file-ignores]
+"scripts/hatch_build.py" = [
+ "INP001", # Top-level build hook
+]
"tests/**" = [
"S101", # assert is pytest's API
"D100", "D101", "D102", "D104", "D107", # no docstrings needed
@@ -143,7 +144,7 @@ max-args = 10
python-version = "3.11"
[tool.ty.src]
-include = ["rampart", "tests"]
+include = ["rampart", "tests", "scripts"]
[tool.uv.sources]
pyrit = { git = "https://github.com/microsoft/PyRIT", rev = "6dc8b94139757390286bbce7d53c1f7e58e66e29" } # v0.13.0
diff --git a/scripts/hatch_build.py b/scripts/hatch_build.py
new file mode 100644
index 00000000..70e6a4c9
--- /dev/null
+++ b/scripts/hatch_build.py
@@ -0,0 +1,81 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+"""Hatchling metadata hooks for RAMPART package builds."""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+from hatchling.metadata.plugin.interface import MetadataHookInterface
+
+_GITHUB_IMAGE_URL_PATTERNS = (
+ re.compile(r"(https://github\.com/microsoft/RAMPART/raw/)main(/docs/images/)"),
+ re.compile(
+ r"(https://raw\.githubusercontent\.com/microsoft/RAMPART/)main(/docs/images/)",
+ ),
+)
+_RELATIVE_HTML_IMAGE_URL_PATTERNS = (
+ re.compile(r'(
]*\bsrc=")(?:\./)?(docs/images/[^"]+)(")'),
+ re.compile(r"(
]*\bsrc=')(?:\./)?(docs/images/[^']+)(')"),
+)
+_RELATIVE_MARKDOWN_IMAGE_URL_PATTERN = re.compile(
+ r"(!\[[^\]]*\]\()(?:\./)?(docs/images/[^)]+)(\))",
+)
+
+
+def _readme_ref(version: str) -> str:
+ """Return the Git ref to use for README image URLs."""
+ if ".dev" in version or "+" in version:
+ return "main"
+
+ return f"v{version}"
+
+
+def _raw_image_url(*, readme_ref: str, image_path: str) -> str:
+ """Return an absolute GitHub raw URL for a README image."""
+ return (
+ f"https://raw.githubusercontent.com/microsoft/RAMPART/{readme_ref}/{image_path}"
+ )
+
+
+def _render_readme(*, root: Path, version: str) -> str:
+ """Render README content for package metadata."""
+ readme = (root / "README.md").read_text(encoding="utf-8")
+ readme_ref = _readme_ref(version)
+
+ for pattern in _GITHUB_IMAGE_URL_PATTERNS:
+ readme = pattern.sub(rf"\g<1>{readme_ref}\g<2>", readme)
+
+ for pattern in _RELATIVE_HTML_IMAGE_URL_PATTERNS:
+ readme = pattern.sub(
+ lambda match: (
+ f"{match.group(1)}"
+ f"{_raw_image_url(readme_ref=readme_ref, image_path=match.group(2))}"
+ f"{match.group(3)}"
+ ),
+ readme,
+ )
+
+ return _RELATIVE_MARKDOWN_IMAGE_URL_PATTERN.sub(
+ lambda match: (
+ f"{match.group(1)}"
+ f"{_raw_image_url(readme_ref=readme_ref, image_path=match.group(2))}"
+ f"{match.group(3)}"
+ ),
+ readme,
+ )
+
+
+class ReadmeMetadataHook(MetadataHookInterface):
+ """Generate PyPI README metadata with release-pinned image URLs."""
+
+ def update(self, metadata: dict[str, object]) -> None:
+ """Update project metadata in-place."""
+ metadata["readme"] = {
+ "content-type": "text/markdown",
+ "text": _render_readme(
+ root=Path(self.root),
+ version=str(metadata["version"]),
+ ),
+ }
diff --git a/uv.lock b/uv.lock
index ad308da4..69ea4e08 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1065,6 +1065,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
]
+[[package]]
+name = "hatchling"
+version = "1.30.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+ { name = "pathspec" },
+ { name = "pluggy" },
+ { name = "trove-classifiers" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/63/4c/8717ccb844b4fa5a5ba6352e97d743ed24e9a22cf90b7c109c17030a46a1/hatchling-1.30.1.tar.gz", hash = "sha256:eee4fd45357f72ebb3d7a42e5d72cfb5e29ed426d79e8836288926c4258d5f2e", size = 56929, upload-time = "2026-06-02T00:09:41.487Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/56/49/2797ec0ef88008a653a8867bb8d1e5c223cd2df8e40390dd5c6a0279cbc5/hatchling-1.30.1-py3-none-any.whl", hash = "sha256:161eacafb3c6f91526e92116d21426369f2c36e98c36a864f11a96345ad4ee31", size = 77489, upload-time = "2026-06-02T00:09:40.139Z" },
+]
+
[[package]]
name = "hf-xet"
version = "1.5.1"
@@ -3016,6 +3031,7 @@ onedrive = [
[package.dev-dependencies]
dev = [
+ { name = "hatchling" },
{ name = "pre-commit" },
{ name = "pytest-cov" },
{ name = "pytest-xdist", extra = ["psutil"] },
@@ -3044,6 +3060,7 @@ provides-extras = ["onedrive"]
[package.metadata.requires-dev]
dev = [
+ { name = "hatchling", specifier = ">=1.30.1" },
{ name = "pre-commit", specifier = ">=4.5.1" },
{ name = "pytest-cov", specifier = ">=6.1.0" },
{ name = "pytest-xdist", extras = ["psutil"], specifier = ">=3.8.0" },
@@ -3589,6 +3606,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/24/32361f5d0e2eff7ff1881ac6833b6b090cfe34515b1ee9082636cbe69442/treelib-1.8.0-py3-none-any.whl", hash = "sha256:5235d1ebf988c5026f26ce6e5e0cd470007f16d4978185f5c9b3eee8a25aef81", size = 30728, upload-time = "2025-06-29T15:06:48.248Z" },
]
+[[package]]
+name = "trove-classifiers"
+version = "2026.6.1.19"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c2/e3/7ca82ee24c82d344584abd5b8637b3bd056f2900226e8d82fc22f1184b92/trove_classifiers-2026.6.1.19.tar.gz", hash = "sha256:c5132b4b61a829d11cfbd2d72e97f20a45ed6edb95e45c5efdeb5e00836b2745", size = 17059, upload-time = "2026-06-01T19:41:34.649Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl", hash = "sha256:ab4c4ec93cc4a4e7815fa759906e05e6bb3f2fbd92ea0f897288c6a43efd15b3", size = 14211, upload-time = "2026-06-01T19:41:33.434Z" },
+]
+
[[package]]
name = "ty"
version = "0.0.56"
From db83f3e5a93b2eccfb73b13c2694b1f07baa44d2 Mon Sep 17 00:00:00 2001
From: spencrr <23708360+spencrr@users.noreply.github.com>
Date: Mon, 22 Jun 2026 14:13:05 -0700
Subject: [PATCH 02/11] [META]: Derive package version from Git tags
---
docs/contributing/release-process.md | 22 +++++++-----
pyproject.toml | 12 +++++--
uv.lock | 51 +++++++++++++++++++++++++++-
3 files changed, 73 insertions(+), 12 deletions(-)
diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md
index c0e52749..9d00b86c 100644
--- a/docs/contributing/release-process.md
+++ b/docs/contributing/release-process.md
@@ -34,15 +34,21 @@ If you find functionality to remove, merge the removal PR to `main` before proce
## 4. Update the Version
-### pyproject.toml
-Set the version in `pyproject.toml` to the version established in step 2.
+### Git tag
+RAMPART derives package versions from Git tags using Hatch VCS and setuptools-scm. No `pyproject.toml` version bump is required for a release. The release version is determined by the `vx.y.z` tag pushed in step 5.
```toml
-[project]
-name = "RAMPART"
-version = "x.y.z"
+[tool.hatch.version]
+source = "vcs"
+
+[tool.hatch.version.raw-options]
+local_scheme = "no-local-version"
```
+The `no-local-version` setting omits local version suffixes such as `+g` because PyPI does not support them for upstream releases. See the [setuptools-scm local scheme documentation](https://setuptools-scm.readthedocs.io/en/latest/extending/#setuptools_scmlocal_scheme) for details.
+
+For development builds on `main`, the release tag must be reachable from `main` history for Hatch VCS to infer the next development version from that tag. If the release branch contains commits beyond `main`, merge or cherry-pick those release commits back to `main` after publishing.
+
### Update README File
The README file is published to PyPI and also needs to be updated so the links work properly. _Note: There may not be any links to update, but it is good practice to check in case our README changes._
@@ -149,7 +155,7 @@ If successful, the URL `https://pypi.org/project/rampart/x.y.z/` will return the
After the release is on PyPI, open a PR to `main` containing only:
-- In line with PyPA [versioning guidance](https://packaging.python.org/en/latest/discussions/versioning/), bump the version in `pyproject.toml` to the next development version (e.g., `x.y.(z+1).dev0` or `x.(y+1).0.dev0`, depending on the next planned release).
+- Any follow-up documentation or metadata updates needed after the release. Do not bump the package version in `pyproject.toml`; once `main` has commits after the release tag, Hatch VCS will infer the next development version automatically.
- Replace any references to the previous release version in the codebase with the new released version (without `.dev0`) where applicable (e.g., installation docs that pin to the latest tag).
Open this PR from a branch separate from your `releases/vx.y.z` branch.
@@ -216,10 +222,10 @@ A patch release (e.g., `0.2.0` → `0.2.1`) ships a targeted fix — typically a
Resolve any conflicts manually. Patch-sized fixes typically apply cleanly.
-3. **Bump the version** in `pyproject.toml` to the new patch version. Also update any version-pinned links in `README.md`.
+3. **Update release-specific references** as needed. Do not bump the package version in `pyproject.toml`; the patch version comes from the `vx.y.z` tag. Also update any version-pinned links in `README.md`.
```bash
- git commit -am "Bump version to x.y.z"
+ git commit -am "Prepare x.y.z release"
```
4. **Push and tag**:
diff --git a/pyproject.toml b/pyproject.toml
index 705cb256..72941b0d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,12 +1,11 @@
[build-system]
-requires = ["hatchling>=1.30.1"]
+requires = ["hatchling>=1.30.1", "hatch-vcs>=0.5.0"]
build-backend = "hatchling.build"
[project]
name = "RAMPART"
-version = "0.1.1.dev0"
description = "A pytest-native safety testing framework for agentic AI applications"
-dynamic = ["readme"]
+dynamic = ["readme", "version"]
license = "MIT"
requires-python = ">=3.11"
authors = [
@@ -43,6 +42,7 @@ onedrive = [
[dependency-groups]
dev = [
+ "hatch-vcs>=0.5.0",
"hatchling>=1.30.1",
"pre-commit>=4.5.1",
"pytest-cov>=6.1.0",
@@ -68,6 +68,12 @@ rampart = "rampart.pytest_plugin.plugin"
[tool.hatch.metadata.hooks.custom]
path = "scripts/hatch_build.py"
+[tool.hatch.version]
+source = "vcs"
+
+[tool.hatch.version.raw-options]
+local_scheme = "no-local-version"
+
[tool.hatch.build.targets.wheel]
packages = ["rampart"]
diff --git a/uv.lock b/uv.lock
index 69ea4e08..be310074 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1065,6 +1065,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
]
+[[package]]
+name = "hatch-vcs"
+version = "0.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "hatchling" },
+ { name = "setuptools-scm" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6b/b0/4cc743d38adbee9d57d786fa496ed1daadb17e48589b6da8fa55717a0746/hatch_vcs-0.5.0.tar.gz", hash = "sha256:0395fa126940340215090c344a2bf4e2a77bcbe7daab16f41b37b98c95809ff9", size = 11424, upload-time = "2025-05-27T05:16:04.49Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5f/48/1f85cee4b7b4f40b9b814b1febbc661bda6ced9649e410a0b74f6e415dd0/hatch_vcs-0.5.0-py3-none-any.whl", hash = "sha256:b49677dbdc597460cc22d01b27ab3696f5e16a21ecf2700fb01bc28e1f2a04a7", size = 8507, upload-time = "2025-05-27T05:16:03.184Z" },
+]
+
[[package]]
name = "hatchling"
version = "1.30.1"
@@ -3012,7 +3025,6 @@ wheels = [
[[package]]
name = "rampart"
-version = "0.1.1.dev0"
source = { editable = "." }
dependencies = [
{ name = "jinja2" },
@@ -3031,6 +3043,7 @@ onedrive = [
[package.dev-dependencies]
dev = [
+ { name = "hatch-vcs" },
{ name = "hatchling" },
{ name = "pre-commit" },
{ name = "pytest-cov" },
@@ -3060,6 +3073,7 @@ provides-extras = ["onedrive"]
[package.metadata.requires-dev]
dev = [
+ { name = "hatch-vcs", specifier = ">=0.5.0" },
{ name = "hatchling", specifier = ">=1.30.1" },
{ name = "pre-commit", specifier = ">=4.5.1" },
{ name = "pytest-cov", specifier = ">=6.1.0" },
@@ -3348,6 +3362,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/02/12c73fd423eb9577b97fc1924966b929eff7074ae6b2e15dd3d30cb9e4ae/segno-1.6.6-py3-none-any.whl", hash = "sha256:28c7d081ed0cf935e0411293a465efd4d500704072cdb039778a2ab8736190c7", size = 76503, upload-time = "2025-03-12T22:12:48.106Z" },
]
+[[package]]
+name = "setuptools"
+version = "82.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
+]
+
+[[package]]
+name = "setuptools-scm"
+version = "10.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+ { name = "setuptools" },
+ { name = "vcs-versioning" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a6/3e/edb74671eca6429f375244d1d6395c11b7d4832cda772e4c630141e121c7/setuptools_scm-10.1.1.tar.gz", hash = "sha256:c9eed4754da1a25016d49c1b3cd09c7c8e65f816b5afb8195bf2ac3c6748f23a", size = 66514, upload-time = "2026-06-22T14:15:44.086Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/a8/3e86057d0d6274e57b9b0b40cb14b90832552c33a8f855b3675843686284/setuptools_scm-10.1.1-py3-none-any.whl", hash = "sha256:4660e6a3b1764ff4b11188de93c26f02396ef294784a75e031e0a5f60c2379fe", size = 27692, upload-time = "2026-06-22T14:15:42.804Z" },
+]
+
[[package]]
name = "shellingham"
version = "1.5.4"
@@ -3776,6 +3813,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
+[[package]]
+name = "vcs-versioning"
+version = "2.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c6/5d/e6c5d8be9f637b7ac6cb83c2c51675c431092b76543866f9c152f3321a76/vcs_versioning-2.0.1.tar.gz", hash = "sha256:0e827e50ff98c3b74961bc9bab1bdb494d6ef9f9624bc0466afbafef23ff0d8c", size = 127905, upload-time = "2026-06-22T14:15:51.531Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/36/51/b9b812b8d09584d8cdfc4c19328e5a86e72f6da310d529cc009d194ac6a7/vcs_versioning-2.0.1-py3-none-any.whl", hash = "sha256:fa1a7e49745fb968af54d1422e1f6bcde2563046f2c283a1a8d720184ece6ea1", size = 105175, upload-time = "2026-06-22T14:15:50.132Z" },
+]
+
[[package]]
name = "virtualenv"
version = "21.5.1"
From 53ed5b8fd55b84591df023a52e98e07b6edef180 Mon Sep 17 00:00:00 2001
From: spencrr <23708360+spencrr@users.noreply.github.com>
Date: Mon, 22 Jun 2026 14:21:36 -0700
Subject: [PATCH 03/11] fixup quotes
---
docs/contributing/release-process.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md
index 9d00b86c..cf128836 100644
--- a/docs/contributing/release-process.md
+++ b/docs/contributing/release-process.md
@@ -54,9 +54,9 @@ The README file is published to PyPI and also needs to be updated so the links w
Keep README image links relative when they point to files in this repository, e.g., `docs/images/RAMPART.svg`. During package builds, `scripts/hatch_build.py` generates the PyPI README metadata and rewrites those image paths to raw GitHub URLs with the release version.
-Replace any other “main” links like “doc/index.md” with “raw” links that have the correct version number, i.e., “https://raw.githubusercontent.com/microsoft/RAMPART/releases/vx.y.z/docs/index.md”.
+Replace any other "main" links like "doc/index.md" with "raw" links that have the correct version number, i.e., "https://raw.githubusercontent.com/microsoft/RAMPART/releases/vx.y.z/docs/index.md".
-For directories, update using the “tree” link, e.g., “https://github.com/microsoft/RAMPART/tree/releases/vx.y.z/docs/usage"
+For directories, update using the "tree" link, e.g., "https://github.com/microsoft/RAMPART/tree/releases/vx.y.z/docs/usage"
This is required for the release branch because PyPI does not pick up other files besides the README, which results in local links breaking.
From 986fe34328add6c97f859385b24c254aeae8723c Mon Sep 17 00:00:00 2001
From: spencrr <23708360+spencrr@users.noreply.github.com>
Date: Tue, 14 Jul 2026 14:30:58 -0700
Subject: [PATCH 04/11] [DOCS]: Land release tag on main so dev versions sort
correctly
Tagging the release branch left vX.Y.Z unreachable from main, so hatch-vcs fell back to counting commits from the repository root and produced 0.1.devN versions that sort before the shipped release. Update the release process to tag a commit on main first and cut releases/vX.Y from that tag, correct the cherry-pick guidance (a cherry-pick creates a new SHA that the tag does not point to, so reachability still fails), re-tag the main commit when fixing issues found during testing, and note the next development version follows automatically.
---
docs/contributing/release-process.md | 50 ++++++++++++++++------------
1 file changed, 28 insertions(+), 22 deletions(-)
diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md
index cf128836..db7cf4cc 100644
--- a/docs/contributing/release-process.md
+++ b/docs/contributing/release-process.md
@@ -47,29 +47,35 @@ local_scheme = "no-local-version"
The `no-local-version` setting omits local version suffixes such as `+g` because PyPI does not support them for upstream releases. See the [setuptools-scm local scheme documentation](https://setuptools-scm.readthedocs.io/en/latest/extending/#setuptools_scmlocal_scheme) for details.
-For development builds on `main`, the release tag must be reachable from `main` history for Hatch VCS to infer the next development version from that tag. If the release branch contains commits beyond `main`, merge or cherry-pick those release commits back to `main` after publishing.
+For development builds on `main` to version correctly, the release tag must be reachable from `main`, meaning it points at a commit that is part of `main`'s history. If it is not, `git describe` finds no tag, setuptools-scm counts commits from the repository root instead, and builds come out as `x.y.devN` versions that sort *before* the release.
-### Update README File
-The README file is published to PyPI and also needs to be updated so the links work properly. _Note: There may not be any links to update, but it is good practice to check in case our README changes._
+Tagging the release branch does not satisfy this, because the release branch is never merged into `main`. Cherry-picking the release commit back to `main` does not help either: cherry-pick creates a new commit with a different SHA that the tag does not point to. Instead, tag a commit that is already on `main` and cut the release branch from that tag, as described in step 5.
-Keep README image links relative when they point to files in this repository, e.g., `docs/images/RAMPART.svg`. During package builds, `scripts/hatch_build.py` generates the PyPI README metadata and rewrites those image paths to raw GitHub URLs with the release version.
+### Update README File
+The README is published to PyPI, so any repository-relative links must resolve for someone reading it there. Because the release is tagged on `main` (step 5), the published README is `main`'s README; there is no separate release-branch copy to maintain.
-Replace any other "main" links like "doc/index.md" with "raw" links that have the correct version number, i.e., "https://raw.githubusercontent.com/microsoft/RAMPART/releases/vx.y.z/docs/index.md".
+Image links can stay relative, e.g., `docs/images/RAMPART.svg`. During package builds, `scripts/hatch_build.py` rewrites those image paths to raw GitHub URLs pinned to the release version.
-For directories, update using the "tree" link, e.g., "https://github.com/microsoft/RAMPART/tree/releases/vx.y.z/docs/usage"
+If the README gains other repository-relative links (for example to `docs/index.md` or a directory), make them absolute `https://github.com/microsoft/RAMPART/...` URLs on `main`, or extend `scripts/hatch_build.py` to rewrite them at build time the way it already does for images. Do not fix these with a release-only commit on the release branch, because the tag must stay on a commit that is part of `main`.
-This is required for the release branch because PyPI does not pick up other files besides the README, which results in local links breaking.
+## 5. Tag the Release on `main` and Publish the Release Branch
-## 5. Publish the Release Branch to GitHub
+Tag the release on `main` first, then cut the release branch from that tag. Tagging `main` rather than the release branch is what keeps the tag reachable from `main`, so development builds version correctly (see the [Git tag](#git-tag) note in step 4).
-Commit your changes to a release branch and push the tag:
+Confirm any release-prep changes have already merged to `main`, then:
```bash
-git checkout -b releases/vx.y.z
-git commit -am "release vx.y.z"
-git push origin releases/vx.y.z
+git checkout main
+git pull origin main
+
+# Tag the current main commit and push the tag.
git tag -a vx.y.z -m "vx.y.z release"
-git push --tags
+git push origin vx.y.z
+
+# Cut the release branch from the tagged commit, for release-only
+# artifacts and future patch releases.
+git checkout -b releases/vx.y.z vx.y.z
+git push origin releases/vx.y.z
```
@@ -126,19 +132,19 @@ Confirm the version matches the release and the package is installed under the e
uv run pytest path/to/RAMPART/tests/integration/test_smoke.py -v
```
-If you need to make changes to fix issues found during testing, cherry-pick from `main` after the fix lands:
+If you need to make changes to fix issues found during testing, land the fix on `main` first, then move the tag to the new `main` commit so it stays reachable from `main`:
```bash
-git checkout main && git pull
-git log main # find the commit hash to cherry-pick
-git checkout releases/vx.y.z
-git cherry-pick
-git push origin releases/vx.y.z
+git checkout main && git pull origin main
+# After the fix has merged to main:
git tag -a vx.y.z -m "vx.y.z release" --force
-git push --tags --force
+git push origin vx.y.z --force
+# Point the release branch at the retagged commit.
+git branch -f releases/vx.y.z vx.y.z
+git push origin releases/vx.y.z --force
```
-Rebuild the package after any cherry-pick and re-test.
+Rebuild the package after re-tagging and re-test.
## 8. Publish to PyPI
@@ -155,7 +161,7 @@ If successful, the URL `https://pypi.org/project/rampart/x.y.z/` will return the
After the release is on PyPI, open a PR to `main` containing only:
-- Any follow-up documentation or metadata updates needed after the release. Do not bump the package version in `pyproject.toml`; once `main` has commits after the release tag, Hatch VCS will infer the next development version automatically.
+- Any follow-up documentation or metadata updates needed after the release. Do not bump the package version in `pyproject.toml`. Because the release was tagged on `main` in step 5, the next commit merged to `main` produces the next development version (for example `x.y.(z+1).devN`) automatically.
- Replace any references to the previous release version in the codebase with the new released version (without `.dev0`) where applicable (e.g., installation docs that pin to the latest tag).
Open this PR from a branch separate from your `releases/vx.y.z` branch.
From 881bcd3a6a0c76a94af13fce66d6025acf95f105 Mon Sep 17 00:00:00 2001
From: spencrr <23708360+spencrr@users.noreply.github.com>
Date: Tue, 14 Jul 2026 17:34:32 -0700
Subject: [PATCH 05/11] [FIX]: Preserve Markdown image titles when rewriting
README image URLs
The Markdown image pattern captured everything up to the closing parenthesis as the URL, so an optional image title was folded into the rewritten link. Stop the URL group at whitespace and keep the title in the trailing group. Also document that only docs/images/ paths are rewritten.
---
scripts/hatch_build.py | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/scripts/hatch_build.py b/scripts/hatch_build.py
index 70e6a4c9..c6b3cc62 100644
--- a/scripts/hatch_build.py
+++ b/scripts/hatch_build.py
@@ -9,6 +9,9 @@
from hatchling.metadata.plugin.interface import MetadataHookInterface
+# These patterns only rewrite ``docs/images/`` paths; images stored elsewhere
+# (for example ``assets/``) pass through unchanged and must already use an
+# absolute URL to render on PyPI.
_GITHUB_IMAGE_URL_PATTERNS = (
re.compile(r"(https://github\.com/microsoft/RAMPART/raw/)main(/docs/images/)"),
re.compile(
@@ -19,8 +22,11 @@
re.compile(r'(
]*\bsrc=")(?:\./)?(docs/images/[^"]+)(")'),
re.compile(r"(
]*\bsrc=')(?:\./)?(docs/images/[^']+)(')"),
)
+# The URL group stops at whitespace so an optional Markdown title, as in
+# ````, is preserved in group 3 instead of
+# being folded into the rewritten image URL.
_RELATIVE_MARKDOWN_IMAGE_URL_PATTERN = re.compile(
- r"(!\[[^\]]*\]\()(?:\./)?(docs/images/[^)]+)(\))",
+ r"(!\[[^\]]*\]\()(?:\./)?(docs/images/[^)\s]+)([^)]*\))",
)
@@ -40,7 +46,7 @@ def _raw_image_url(*, readme_ref: str, image_path: str) -> str:
def _render_readme(*, root: Path, version: str) -> str:
- """Render README content for package metadata."""
+ """Return README content rendered for package metadata."""
readme = (root / "README.md").read_text(encoding="utf-8")
readme_ref = _readme_ref(version)
From 02eff4bbc41cdba92bd3b5b91e64ee539c44b4d2 Mon Sep 17 00:00:00 2001
From: spencrr <23708360+spencrr@users.noreply.github.com>
Date: Tue, 14 Jul 2026 17:34:33 -0700
Subject: [PATCH 06/11] [TEST]: Cover the README metadata build hook
Add tests for _readme_ref, _raw_image_url, and _render_readme in scripts/hatch_build.py, covering relative and ./ HTML images, Markdown images, titled Markdown images, absolute main-URL repinning, dev-version passthrough, and no-image passthrough. Make the hook importable in tests via pytest pythonpath and ty extra-paths.
---
pyproject.toml | 2 +
tests/scripts/__init__.py | 2 +
tests/scripts/test_hatch_build.py | 93 +++++++++++++++++++++++++++++++
3 files changed, 97 insertions(+)
create mode 100644 tests/scripts/__init__.py
create mode 100644 tests/scripts/test_hatch_build.py
diff --git a/pyproject.toml b/pyproject.toml
index 72941b0d..7bb6ad9b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -87,6 +87,7 @@ show_missing = true
skip_empty = true
[tool.pytest.ini_options]
+pythonpath = ["scripts"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
markers = [
@@ -148,6 +149,7 @@ max-args = 10
[tool.ty.environment]
python-version = "3.11"
+extra-paths = ["scripts"]
[tool.ty.src]
include = ["rampart", "tests", "scripts"]
diff --git a/tests/scripts/__init__.py b/tests/scripts/__init__.py
new file mode 100644
index 00000000..9a045456
--- /dev/null
+++ b/tests/scripts/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
diff --git a/tests/scripts/test_hatch_build.py b/tests/scripts/test_hatch_build.py
new file mode 100644
index 00000000..43e484bc
--- /dev/null
+++ b/tests/scripts/test_hatch_build.py
@@ -0,0 +1,93 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Tests for the packaging README metadata hook in ``scripts/hatch_build.py``."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import hatch_build
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+RAW = "https://raw.githubusercontent.com/microsoft/RAMPART"
+
+
+def _write_readme(root: Path, body: str) -> None:
+ (root / "README.md").write_text(body, encoding="utf-8")
+
+
+class TestReadmeRef:
+ """_readme_ref pins released versions and falls back to main otherwise."""
+
+ def test_release_version_uses_tag(self) -> None:
+ assert hatch_build._readme_ref("0.1.0") == "v0.1.0"
+
+ def test_dev_version_uses_main(self) -> None:
+ assert hatch_build._readme_ref("0.1.1.dev35") == "main"
+
+ def test_local_version_uses_main(self) -> None:
+ assert hatch_build._readme_ref("0.1.0+g1234567") == "main"
+
+
+class TestRawImageUrl:
+ """_raw_image_url builds a ref-pinned raw GitHub URL."""
+
+ def test_builds_pinned_url(self) -> None:
+ url = hatch_build._raw_image_url(
+ readme_ref="v0.1.0",
+ image_path="docs/images/RAMPART.svg",
+ )
+ assert url == f"{RAW}/v0.1.0/docs/images/RAMPART.svg"
+
+
+class TestRenderReadme:
+ """_render_readme rewrites docs/images references for the release ref."""
+
+ def test_relative_html_image_rewritten(self, tmp_path: Path) -> None:
+ _write_readme(tmp_path, '
')
+ out = hatch_build._render_readme(root=tmp_path, version="0.1.0")
+ assert f'src="{RAW}/v0.1.0/docs/images/RAMPART.svg"' in out
+
+ def test_dotslash_html_image_rewritten(self, tmp_path: Path) -> None:
+ _write_readme(tmp_path, '
')
+ out = hatch_build._render_readme(root=tmp_path, version="0.1.0")
+ assert f"{RAW}/v0.1.0/docs/images/RAMPART.svg" in out
+ assert "./docs/images" not in out
+
+ def test_markdown_image_rewritten(self, tmp_path: Path) -> None:
+ _write_readme(tmp_path, "")
+ out = hatch_build._render_readme(root=tmp_path, version="0.1.0")
+ assert out == f""
+
+ def test_titled_markdown_image_preserves_title(self, tmp_path: Path) -> None:
+ _write_readme(tmp_path, '')
+ out = hatch_build._render_readme(root=tmp_path, version="0.1.0")
+ assert out == f''
+
+ def test_absolute_github_raw_main_repinned(self, tmp_path: Path) -> None:
+ _write_readme(
+ tmp_path,
+ '
',
+ )
+ out = hatch_build._render_readme(root=tmp_path, version="0.1.0")
+ assert "raw/v0.1.0/docs/images/RAMPART.svg" in out
+ assert "raw/main/docs/images" not in out
+
+ def test_absolute_raw_host_main_repinned(self, tmp_path: Path) -> None:
+ _write_readme(tmp_path, f"{RAW}/main/docs/images/RAMPART.svg")
+ out = hatch_build._render_readme(root=tmp_path, version="0.1.0")
+ assert f"{RAW}/v0.1.0/docs/images/RAMPART.svg" in out
+ assert f"{RAW}/main/docs/images" not in out
+
+ def test_dev_version_keeps_main_ref(self, tmp_path: Path) -> None:
+ _write_readme(tmp_path, "")
+ out = hatch_build._render_readme(root=tmp_path, version="0.1.1.dev35")
+ assert out == f""
+
+ def test_no_image_passthrough(self, tmp_path: Path) -> None:
+ body = "# RAMPART\n\nNo images, only [a link](https://example.com).\n"
+ _write_readme(tmp_path, body)
+ assert hatch_build._render_readme(root=tmp_path, version="0.1.0") == body
From ebb0dac433ab97f25a09f376da672bdabd2ddb77 Mon Sep 17 00:00:00 2001
From: spencrr <23708360+spencrr@users.noreply.github.com>
Date: Tue, 14 Jul 2026 17:34:33 -0700
Subject: [PATCH 07/11] [CI]: Run script tests when scripts change
Add a workflow that runs pytest tests/scripts on pushes and pull requests that touch scripts/, tests/scripts/, or the workflow file.
---
.github/workflows/scripts.yml | 50 +++++++++++++++++++++++++++++++++++
1 file changed, 50 insertions(+)
create mode 100644 .github/workflows/scripts.yml
diff --git a/.github/workflows/scripts.yml b/.github/workflows/scripts.yml
new file mode 100644
index 00000000..6e0491b1
--- /dev/null
+++ b/.github/workflows/scripts.yml
@@ -0,0 +1,50 @@
+name: Scripts
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - "scripts/**"
+ - "tests/scripts/**"
+ - ".github/workflows/scripts.yml"
+ pull_request:
+ branches: [main]
+ paths:
+ - "scripts/**"
+ - "tests/scripts/**"
+ - ".github/workflows/scripts.yml"
+
+concurrency:
+ group: scripts-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions: {}
+
+jobs:
+ test:
+ name: Test scripts
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
+ with:
+ python-version: "3.12"
+
+ - name: Set up uv
+ uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
+ with:
+ enable-cache: true
+
+ - name: Install dependencies
+ run: uv sync --all-extras --frozen
+
+ - name: Run script tests
+ run: uv run pytest tests/scripts
From 5bf8c13ff574ef1c463fb7c7c45a569949a7d576 Mon Sep 17 00:00:00 2001
From: spencrr <23708360+spencrr@users.noreply.github.com>
Date: Wed, 15 Jul 2026 16:34:21 -0700
Subject: [PATCH 08/11] [DOCS]: Clarify release metadata preparation steps
Separate the maintainer checklist from the Hatch VCS and tag-reachability background in the release process. Also add concrete examples of patch release references and remove repeated version-bump guidance.
---
docs/contributing/release-process.md | 26 ++++++++++++++------------
1 file changed, 14 insertions(+), 12 deletions(-)
diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md
index db7cf4cc..6952a2a1 100644
--- a/docs/contributing/release-process.md
+++ b/docs/contributing/release-process.md
@@ -32,10 +32,19 @@ If you are incrementing the minor version, search the codebase for the new minor
If you find functionality to remove, merge the removal PR to `main` before proceeding.
-## 4. Update the Version
+## 4. Prepare Release Metadata
-### Git tag
-RAMPART derives package versions from Git tags using Hatch VCS and setuptools-scm. No `pyproject.toml` version bump is required for a release. The release version is determined by the `vx.y.z` tag pushed in step 5.
+Before tagging the release:
+
+- Do not add or update a version in `pyproject.toml`. The release version comes from the `vx.y.z` tag created in step 5.
+- Review `README.md` for repository-relative links that need to work on PyPI.
+- Keep image links under `docs/images/` relative. During package builds, `scripts/hatch_build.py` rewrites them to raw GitHub URLs pinned to the release version.
+- For other repository-relative links, use absolute `https://github.com/microsoft/RAMPART/...` URLs on `main`, or extend `scripts/hatch_build.py` to rewrite them at build time.
+- Merge any required README or metadata changes to `main` before continuing to step 5.
+
+### Why the Tag Must Be on `main`
+
+RAMPART derives package versions from Git tags using Hatch VCS and setuptools-scm:
```toml
[tool.hatch.version]
@@ -51,16 +60,9 @@ For development builds on `main` to version correctly, the release tag must be r
Tagging the release branch does not satisfy this, because the release branch is never merged into `main`. Cherry-picking the release commit back to `main` does not help either: cherry-pick creates a new commit with a different SHA that the tag does not point to. Instead, tag a commit that is already on `main` and cut the release branch from that tag, as described in step 5.
-### Update README File
-The README is published to PyPI, so any repository-relative links must resolve for someone reading it there. Because the release is tagged on `main` (step 5), the published README is `main`'s README; there is no separate release-branch copy to maintain.
-
-Image links can stay relative, e.g., `docs/images/RAMPART.svg`. During package builds, `scripts/hatch_build.py` rewrites those image paths to raw GitHub URLs pinned to the release version.
-
-If the README gains other repository-relative links (for example to `docs/index.md` or a directory), make them absolute `https://github.com/microsoft/RAMPART/...` URLs on `main`, or extend `scripts/hatch_build.py` to rewrite them at build time the way it already does for images. Do not fix these with a release-only commit on the release branch, because the tag must stay on a commit that is part of `main`.
-
## 5. Tag the Release on `main` and Publish the Release Branch
-Tag the release on `main` first, then cut the release branch from that tag. Tagging `main` rather than the release branch is what keeps the tag reachable from `main`, so development builds version correctly (see the [Git tag](#git-tag) note in step 4).
+Tag the release on `main` first, then cut the release branch from that tag. Tagging `main` rather than the release branch is what keeps the tag reachable from `main`, so development builds version correctly (see [Why the Tag Must Be on `main`](#why-the-tag-must-be-on-main) in step 4).
Confirm any release-prep changes have already merged to `main`, then:
@@ -228,7 +230,7 @@ A patch release (e.g., `0.2.0` → `0.2.1`) ships a targeted fix — typically a
Resolve any conflicts manually. Patch-sized fixes typically apply cleanly.
-3. **Update release-specific references** as needed. Do not bump the package version in `pyproject.toml`; the patch version comes from the `vx.y.z` tag. Also update any version-pinned links in `README.md`.
+3. **Update release-specific references** as needed, such as documentation that names the patch version (for example, "Fixed in v0.2.1") or `README.md` links pinned to a release tag.
```bash
git commit -am "Prepare x.y.z release"
From 2dd8b4c4c5e57995b4752bbf2e4a3a7984dbe220 Mon Sep 17 00:00:00 2001
From: spencrr <23708360+spencrr@users.noreply.github.com>
Date: Wed, 15 Jul 2026 17:03:13 -0700
Subject: [PATCH 09/11] [META]: Use branch-aware semantic development versions
Configure setuptools-scm's semver-pep440-release-branch scheme so main advances to the next minor development version while releases/vX.Y advances patch versions. Update and condense the release process around one long-lived branch per minor series with patch fixes cherry-picked from main.
---
docs/contributing/release-process.md | 85 ++++++++++------------------
pyproject.toml | 3 +-
2 files changed, 33 insertions(+), 55 deletions(-)
diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md
index 6952a2a1..4436c4e5 100644
--- a/docs/contributing/release-process.md
+++ b/docs/contributing/release-process.md
@@ -36,33 +36,18 @@ If you find functionality to remove, merge the removal PR to `main` before proce
Before tagging the release:
-- Do not add or update a version in `pyproject.toml`. The release version comes from the `vx.y.z` tag created in step 5.
+- Do not add or update a version in `pyproject.toml`. The release version comes from the Git tag.
- Review `README.md` for repository-relative links that need to work on PyPI.
- Keep image links under `docs/images/` relative. During package builds, `scripts/hatch_build.py` rewrites them to raw GitHub URLs pinned to the release version.
- For other repository-relative links, use absolute `https://github.com/microsoft/RAMPART/...` URLs on `main`, or extend `scripts/hatch_build.py` to rewrite them at build time.
- Merge any required README or metadata changes to `main` before continuing to step 5.
-### Why the Tag Must Be on `main`
+!!! note "Versioning"
+ RAMPART uses Hatch VCS with setuptools-scm's `semver-pep440-release-branch` scheme. After `v0.2.0`, builds from `main` use `0.3.0.devN`, builds from `releases/v0.2` use `0.2.1.devN`, and a tagged commit uses the exact tag. Git only considers tags in the current commit's ancestry, so create `vx.y.0` on `main` before branching; patch tags remain on `releases/vx.y` and do not affect `main`. See the [setuptools-scm version scheme documentation](https://github.com/pypa/setuptools-scm/blob/main/docs/extending.md#available-implementations).
-RAMPART derives package versions from Git tags using Hatch VCS and setuptools-scm:
+## 5. Tag the Minor Release on `main` and Create the Release Branch
-```toml
-[tool.hatch.version]
-source = "vcs"
-
-[tool.hatch.version.raw-options]
-local_scheme = "no-local-version"
-```
-
-The `no-local-version` setting omits local version suffixes such as `+g` because PyPI does not support them for upstream releases. See the [setuptools-scm local scheme documentation](https://setuptools-scm.readthedocs.io/en/latest/extending/#setuptools_scmlocal_scheme) for details.
-
-For development builds on `main` to version correctly, the release tag must be reachable from `main`, meaning it points at a commit that is part of `main`'s history. If it is not, `git describe` finds no tag, setuptools-scm counts commits from the repository root instead, and builds come out as `x.y.devN` versions that sort *before* the release.
-
-Tagging the release branch does not satisfy this, because the release branch is never merged into `main`. Cherry-picking the release commit back to `main` does not help either: cherry-pick creates a new commit with a different SHA that the tag does not point to. Instead, tag a commit that is already on `main` and cut the release branch from that tag, as described in step 5.
-
-## 5. Tag the Release on `main` and Publish the Release Branch
-
-Tag the release on `main` first, then cut the release branch from that tag. Tagging `main` rather than the release branch is what keeps the tag reachable from `main`, so development builds version correctly (see [Why the Tag Must Be on `main`](#why-the-tag-must-be-on-main) in step 4).
+For the first release in a minor series, tag `vx.y.0` on `main`, then create the long-lived `releases/vx.y` branch. Patch releases reuse this branch.
Confirm any release-prep changes have already merged to `main`, then:
@@ -71,13 +56,12 @@ git checkout main
git pull origin main
# Tag the current main commit and push the tag.
-git tag -a vx.y.z -m "vx.y.z release"
-git push origin vx.y.z
+git tag -a vx.y.0 -m "vx.y.0 release"
+git push origin vx.y.0
-# Cut the release branch from the tagged commit, for release-only
-# artifacts and future patch releases.
-git checkout -b releases/vx.y.z vx.y.z
-git push origin releases/vx.y.z
+# Create the branch used for every patch in this minor series.
+git checkout -b releases/vx.y vx.y.0
+git push origin releases/vx.y
```
@@ -134,19 +118,19 @@ Confirm the version matches the release and the package is installed under the e
uv run pytest path/to/RAMPART/tests/integration/test_smoke.py -v
```
-If you need to make changes to fix issues found during testing, land the fix on `main` first, then move the tag to the new `main` commit so it stays reachable from `main`:
+For a minor release, if you need to make changes to fix issues found during testing, land the fix on `main` first, then move the tag to the new `main` commit so it stays reachable from `main`:
```bash
git checkout main && git pull origin main
# After the fix has merged to main:
-git tag -a vx.y.z -m "vx.y.z release" --force
-git push origin vx.y.z --force
+git tag -a vx.y.0 -m "vx.y.0 release" --force
+git push origin vx.y.0 --force
# Point the release branch at the retagged commit.
-git branch -f releases/vx.y.z vx.y.z
-git push origin releases/vx.y.z --force
+git branch -f releases/vx.y vx.y.0
+git push origin releases/vx.y --force
```
-Rebuild the package after re-tagging and re-test.
+Rebuild the package after re-tagging and re-test. For a patch release, land the additional fix on `main`, cherry-pick it onto `releases/vx.y`, move the patch tag to the updated release-branch commit, and re-test.
## 8. Publish to PyPI
@@ -163,14 +147,14 @@ If successful, the URL `https://pypi.org/project/rampart/x.y.z/` will return the
After the release is on PyPI, open a PR to `main` containing only:
-- Any follow-up documentation or metadata updates needed after the release. Do not bump the package version in `pyproject.toml`. Because the release was tagged on `main` in step 5, the next commit merged to `main` produces the next development version (for example `x.y.(z+1).devN`) automatically.
+- Any follow-up documentation or metadata updates needed after the release. Do not bump the package version in `pyproject.toml`.
- Replace any references to the previous release version in the codebase with the new released version (without `.dev0`) where applicable (e.g., installation docs that pin to the latest tag).
-Open this PR from a branch separate from your `releases/vx.y.z` branch.
+Open this PR from a branch separate from your `releases/vx.y` branch.
## 10. Create the GitHub Release
-Go to the [releases page](https://github.com/microsoft/RAMPART/releases), select **Draft a new release**, and choose the tag you pushed in step 5. Click **Generate release notes** to pre-populate the description.
+Go to the [releases page](https://github.com/microsoft/RAMPART/releases), select **Draft a new release**, and choose the tag you pushed in step 5 (or step 4 of the [patch release process](#patch-releases)). Click **Generate release notes** to pre-populate the description.
Structure the description as:
@@ -203,9 +187,9 @@ Re-run the full test suite after bumping — PyRIT changes are a common source o
---
-### Patch Releases (Cherry-Pick Process)
+### Patch Releases
-A patch release (e.g., `0.2.0` → `0.2.1`) ships a targeted fix — typically a security patch or a critical bug fix — without including other in-flight changes from `main`.
+A patch release (e.g., `0.2.0` to `0.2.1`) ships a targeted fix, typically a security patch or a critical bug fix, without including other in-flight changes from `main`. Each minor series has one long-lived branch named `releases/vx.y`; every patch for that series is cherry-picked from `main` onto that branch.
#### When to use a patch release
@@ -215,42 +199,35 @@ A patch release (e.g., `0.2.0` → `0.2.1`) ships a targeted fix — typically a
#### Abbreviated steps
-1. **Create a release branch from the previous tag**, not from `main`:
+1. **Check out the existing minor release branch**:
```bash
git fetch origin
- git checkout -b releases/vx.y.z vx.y.(z-1)
+ git checkout releases/vx.y
+ git pull --ff-only origin releases/vx.y
```
-2. **Cherry-pick the fix** from `main`:
+2. **Cherry-pick the fix** after it has merged to `main`:
```bash
git cherry-pick
```
- Resolve any conflicts manually. Patch-sized fixes typically apply cleanly.
+ Resolve any conflicts manually. Patch-sized fixes typically apply cleanly. Cherry-pick only the commits intended for the patch; do not merge `main` into the release branch.
-3. **Update release-specific references** as needed, such as documentation that names the patch version (for example, "Fixed in v0.2.1") or `README.md` links pinned to a release tag.
+3. **Update release-specific references** as needed, such as documentation that names the patch version (for example, "Fixed in v0.2.1") or `README.md` links pinned to a release tag. Skip this commit if no references need updating.
```bash
- git commit -am "Prepare x.y.z release"
+ git add
+ git commit -m "Prepare x.y.z release"
```
4. **Push and tag**:
```bash
- git push origin releases/vx.y.z
+ git push origin releases/vx.y
git tag -a vx.y.z -m "vx.y.z release"
- git push --tags
+ git push origin vx.y.z
```
5. **Follow the regular release process from step 6 onward**: build, test, publish to PyPI, update `main`, and create the GitHub release. Patch release notes should clearly state the reason for the patch (e.g., "Security fix for…" or "Critical bug fix for…").
-
-#### Key differences from a regular release
-
-| Aspect | Regular release | Patch release |
-|---|---|---|
-| Branch base | `main` | Previous release tag |
-| Changes included | Everything on `main` | Only cherry-picked fix(es) |
-| Deprecated code removal | Yes (if minor bump) | No |
-| Release notes | Full changelog with curated summary | Short, focused on the reason for the patch |
diff --git a/pyproject.toml b/pyproject.toml
index 7bb6ad9b..f5501017 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,5 @@
[build-system]
-requires = ["hatchling>=1.30.1", "hatch-vcs>=0.5.0"]
+requires = ["hatchling>=1.30.1", "hatch-vcs>=0.5.0", "setuptools-scm>=10"]
build-backend = "hatchling.build"
[project]
@@ -72,6 +72,7 @@ path = "scripts/hatch_build.py"
source = "vcs"
[tool.hatch.version.raw-options]
+version_scheme = "semver-pep440-release-branch"
local_scheme = "no-local-version"
[tool.hatch.build.targets.wheel]
From 9d1a2e7c6638b5ac91c1a328f8f9e0ef5b8e055e Mon Sep 17 00:00:00 2001
From: spencrr <23708360+spencrr@users.noreply.github.com>
Date: Wed, 15 Jul 2026 17:28:26 -0700
Subject: [PATCH 10/11] [DOCS]: Validate releases before pushing refs
Keep release tags and branches local through package build and smoke testing, then push them once validation succeeds. Replace forced tag and branch updates with local tag recreation and fast-forward-only branch updates, and document the temporary pre-v0.2.0 development-version fallback.
---
docs/contributing/release-process.md | 39 ++++++++++++++--------------
1 file changed, 19 insertions(+), 20 deletions(-)
diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md
index 4436c4e5..f890d2c1 100644
--- a/docs/contributing/release-process.md
+++ b/docs/contributing/release-process.md
@@ -43,7 +43,7 @@ Before tagging the release:
- Merge any required README or metadata changes to `main` before continuing to step 5.
!!! note "Versioning"
- RAMPART uses Hatch VCS with setuptools-scm's `semver-pep440-release-branch` scheme. After `v0.2.0`, builds from `main` use `0.3.0.devN`, builds from `releases/v0.2` use `0.2.1.devN`, and a tagged commit uses the exact tag. Git only considers tags in the current commit's ancestry, so create `vx.y.0` on `main` before branching; patch tags remain on `releases/vx.y` and do not affect `main`. See the [setuptools-scm version scheme documentation](https://github.com/pypa/setuptools-scm/blob/main/docs/extending.md#available-implementations).
+ RAMPART uses Hatch VCS with setuptools-scm's `semver-pep440-release-branch` scheme. After `v0.2.0`, builds from `main` use `0.3.0.devN`, builds from `releases/v0.2` use `0.2.1.devN`, and a tagged commit uses the exact tag. Git only considers tags in the current commit's ancestry, so create `vx.y.0` on `main` before branching; patch tags remain on `releases/vx.y` and do not affect `main`. Until `v0.2.0` is tagged on `main`, development builds use the `0.1.0.devN` fallback; these builds are not published. See the [setuptools-scm version scheme documentation](https://github.com/pypa/setuptools-scm/blob/main/docs/extending.md#available-implementations).
## 5. Tag the Minor Release on `main` and Create the Release Branch
@@ -55,13 +55,9 @@ Confirm any release-prep changes have already merged to `main`, then:
git checkout main
git pull origin main
-# Tag the current main commit and push the tag.
+# Create the tag and release branch.
git tag -a vx.y.0 -m "vx.y.0 release"
-git push origin vx.y.0
-
-# Create the branch used for every patch in this minor series.
git checkout -b releases/vx.y vx.y.0
-git push origin releases/vx.y
```
@@ -118,21 +114,26 @@ Confirm the version matches the release and the package is installed under the e
uv run pytest path/to/RAMPART/tests/integration/test_smoke.py -v
```
-For a minor release, if you need to make changes to fix issues found during testing, land the fix on `main` first, then move the tag to the new `main` commit so it stays reachable from `main`:
+For a minor release, if testing finds an issue, land the fix on `main`, recreate the local tag on the fixed commit, and fast-forward the local release branch:
```bash
git checkout main && git pull origin main
-# After the fix has merged to main:
-git tag -a vx.y.0 -m "vx.y.0 release" --force
-git push origin vx.y.0 --force
-# Point the release branch at the retagged commit.
-git branch -f releases/vx.y vx.y.0
-git push origin releases/vx.y --force
+git tag -d vx.y.0
+git tag -a vx.y.0 -m "vx.y.0 release"
+git checkout releases/vx.y
+git merge --ff-only vx.y.0
```
-Rebuild the package after re-tagging and re-test. For a patch release, land the additional fix on `main`, cherry-pick it onto `releases/vx.y`, move the patch tag to the updated release-branch commit, and re-test.
+Rebuild the package and re-test. For a patch release, land the additional fix on `main`, cherry-pick it onto `releases/vx.y`, delete and recreate the local patch tag, then re-test.
+
+## 8. Publish the Git References and Package
-## 8. Publish to PyPI
+After all tests pass, push the release branch and tag:
+
+```bash
+git push origin releases/vx.y
+git push origin vx.y.z
+```
Create a PyPI account if you don't have one and ask another maintainer to add you to the `rampart` project. Before publishing, have an API token scoped to the project ready (create one in your PyPI project settings).
@@ -154,7 +155,7 @@ Open this PR from a branch separate from your `releases/vx.y` branch.
## 10. Create the GitHub Release
-Go to the [releases page](https://github.com/microsoft/RAMPART/releases), select **Draft a new release**, and choose the tag you pushed in step 5 (or step 4 of the [patch release process](#patch-releases)). Click **Generate release notes** to pre-populate the description.
+Go to the [releases page](https://github.com/microsoft/RAMPART/releases), select **Draft a new release**, and choose the tag you pushed in step 8. Click **Generate release notes** to pre-populate the description.
Structure the description as:
@@ -222,12 +223,10 @@ A patch release (e.g., `0.2.0` to `0.2.1`) ships a targeted fix, typically a sec
git commit -m "Prepare x.y.z release"
```
-4. **Push and tag**:
+4. **Create the tag locally**. Do not push the branch or tag until testing passes:
```bash
- git push origin releases/vx.y
git tag -a vx.y.z -m "vx.y.z release"
- git push origin vx.y.z
```
-5. **Follow the regular release process from step 6 onward**: build, test, publish to PyPI, update `main`, and create the GitHub release. Patch release notes should clearly state the reason for the patch (e.g., "Security fix for…" or "Critical bug fix for…").
+5. **Follow the regular release process from step 6 onward**: build, test, push the branch and tag, publish to PyPI, update `main`, and create the GitHub release. Patch release notes should clearly state the reason for the patch (e.g., "Security fix for…" or "Critical bug fix for…").
From 88f721d97fc92e392139c1c26cc09d9a6d77923e Mon Sep 17 00:00:00 2001
From: spencrr <23708360+spencrr@users.noreply.github.com>
Date: Wed, 15 Jul 2026 17:33:09 -0700
Subject: [PATCH 11/11] [DOCS]: Complete the patch release workflow
Add an explicit step for choosing an unused patch version and concrete recovery commands for updating the release branch and recreating a local patch tag when testing finds another issue.
---
docs/contributing/release-process.md | 30 +++++++++++++++++++++-------
1 file changed, 23 insertions(+), 7 deletions(-)
diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md
index f890d2c1..850b2b58 100644
--- a/docs/contributing/release-process.md
+++ b/docs/contributing/release-process.md
@@ -124,7 +124,16 @@ git checkout releases/vx.y
git merge --ff-only vx.y.0
```
-Rebuild the package and re-test. For a patch release, land the additional fix on `main`, cherry-pick it onto `releases/vx.y`, delete and recreate the local patch tag, then re-test.
+For a patch release, land the additional fix on `main`, then update the release branch and local tag:
+
+```bash
+git checkout releases/vx.y
+git cherry-pick
+git tag -d vx.y.z
+git tag -a vx.y.z -m "vx.y.z release"
+```
+
+Rebuild the package and re-test.
## 8. Publish the Git References and Package
@@ -198,9 +207,16 @@ A patch release (e.g., `0.2.0` to `0.2.1`) ships a targeted fix, typically a sec
- A critical bug was found in the latest release that blocks users.
- The fix is already merged to `main`, but `main` contains other changes that aren't ready for release.
-#### Abbreviated steps
+#### Steps
+
+1. **Choose the next patch version**. Increment the patch component from the latest published tag and confirm that the new tag does not already exist. For example, use `v0.2.1` after `v0.2.0`:
+
+ ```bash
+ git fetch origin --tags
+ git tag --list "vx.y.*"
+ ```
-1. **Check out the existing minor release branch**:
+2. **Check out the existing minor release branch**:
```bash
git fetch origin
@@ -208,7 +224,7 @@ A patch release (e.g., `0.2.0` to `0.2.1`) ships a targeted fix, typically a sec
git pull --ff-only origin releases/vx.y
```
-2. **Cherry-pick the fix** after it has merged to `main`:
+3. **Cherry-pick the fix** after it has merged to `main`:
```bash
git cherry-pick
@@ -216,17 +232,17 @@ A patch release (e.g., `0.2.0` to `0.2.1`) ships a targeted fix, typically a sec
Resolve any conflicts manually. Patch-sized fixes typically apply cleanly. Cherry-pick only the commits intended for the patch; do not merge `main` into the release branch.
-3. **Update release-specific references** as needed, such as documentation that names the patch version (for example, "Fixed in v0.2.1") or `README.md` links pinned to a release tag. Skip this commit if no references need updating.
+4. **Update release-specific references** as needed, such as documentation that names the patch version (for example, "Fixed in v0.2.1") or `README.md` links pinned to a release tag. Skip this commit if no references need updating.
```bash
git add
git commit -m "Prepare x.y.z release"
```
-4. **Create the tag locally**. Do not push the branch or tag until testing passes:
+5. **Create the tag locally**. Do not push the branch or tag until testing passes:
```bash
git tag -a vx.y.z -m "vx.y.z release"
```
-5. **Follow the regular release process from step 6 onward**: build, test, push the branch and tag, publish to PyPI, update `main`, and create the GitHub release. Patch release notes should clearly state the reason for the patch (e.g., "Security fix for…" or "Critical bug fix for…").
+6. **Follow the regular release process from step 6 onward**: build, test, push the branch and tag, publish to PyPI, update `main`, and create the GitHub release. Patch release notes should clearly state the reason for the patch (e.g., "Security fix for…" or "Critical bug fix for…").