Skip to content

Commit be588eb

Browse files
Configure monorepo release tooling with towncrier-fragments scheme
- Add monorepo-aware configurations to both projects' pyproject.toml - Set tag_regex and git describe_command for project-specific tag matching - Configure fallback versions (setuptools-scm: 9.2.2, vcs-versioning: 0.1.0) - Use towncrier-fragments version scheme - Fix towncrier version scheme to work in monorepo - Look for changelog.d/ relative to config file (relative_to) instead of absolute_root - Enables per-project changelog fragment analysis - Enhance create-release-proposal script - Support local mode (works without GitHub env vars) - Auto-detect current branch when not specified - Use --draft mode for towncrier in local runs (no file changes) - Simplify to use Configuration.from_file() from pyproject.toml Results: - setuptools-scm: 10.0.0 (major bump due to removal fragment) - vcs-versioning: 0.2.0 (minor bump due to feature fragments)
1 parent 5be2678 commit be588eb

File tree

4 files changed

+134
-80
lines changed

4 files changed

+134
-80
lines changed

setuptools-scm/pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ version = { attr = "_own_version_helper.version"}
122122

123123
[tool.setuptools_scm]
124124
root = ".."
125+
version_scheme = "towncrier-fragments"
126+
tag_regex = "^setuptools-scm-(?P<version>v?\\d+(?:\\.\\d+){0,2}[^\\+]*)(?:\\+.*)?$"
127+
fallback_version = "9.2.2" # we trnasion to towncrier informed here
128+
scm.git.describe_command = ["git", "describe", "--dirty", "--tags", "--long", "--match", "setuptools-scm-*"]
125129

126130
[tool.ruff]
127131
lint.extend-select = ["YTT", "B", "C4", "DTZ", "ISC", "LOG", "G", "PIE", "PYI", "PT", "FLY", "I", "C90", "PERF", "W", "PGH", "PLE", "UP", "FURB", "RUF"]

src/vcs_versioning_workspace/create_release_proposal.py

Lines changed: 108 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,10 @@ def get_next_version(project_dir: Path, repo_root: Path) -> str | None:
4444
"""Get the next version for a project using vcs-versioning API."""
4545
try:
4646
# Load configuration from project's pyproject.toml
47+
# All project-specific settings (tag_regex, fallback_version, etc.) are in the config files
48+
# Override local_scheme to get clean version strings
4749
pyproject = project_dir / "pyproject.toml"
48-
config = Configuration.from_file(
49-
pyproject,
50-
root=str(repo_root),
51-
version_scheme="towncrier-fragments",
52-
local_scheme="no-local-version",
53-
)
50+
config = Configuration.from_file(pyproject, local_scheme="no-local-version")
5451

5552
# Get the ScmVersion object
5653
scm_version = parse_version(config)
@@ -69,11 +66,17 @@ def get_next_version(project_dir: Path, repo_root: Path) -> str | None:
6966
return None
7067

7168

72-
def run_towncrier(project_dir: Path, version: str) -> bool:
69+
def run_towncrier(project_dir: Path, version: str, *, draft: bool = False) -> bool:
7370
"""Run towncrier build for a project."""
7471
try:
72+
cmd = ["uv", "run", "towncrier", "build", "--version", version]
73+
if draft:
74+
cmd.append("--draft")
75+
else:
76+
cmd.append("--yes")
77+
7578
result = subprocess.run(
76-
["uv", "run", "towncrier", "build", "--version", version, "--yes"],
79+
cmd,
7780
cwd=project_dir,
7881
capture_output=True,
7982
text=True,
@@ -122,41 +125,63 @@ def main() -> None:
122125
parser = argparse.ArgumentParser(description="Create release proposal")
123126
parser.add_argument(
124127
"--event",
125-
required=True,
126128
help="GitHub event type (push or pull_request)",
127129
)
128130
parser.add_argument(
129131
"--branch",
130-
required=True,
131-
help="Source branch name",
132+
help="Source branch name (defaults to current branch)",
132133
)
133134
args = parser.parse_args()
134135

135136
# Get environment variables
136137
token = os.environ.get("GITHUB_TOKEN")
137138
repo_name = os.environ.get("GITHUB_REPOSITORY")
138-
source_branch = args.branch
139-
is_pr = args.event == "pull_request"
140139

141-
if not token:
142-
print("ERROR: GITHUB_TOKEN environment variable not set", file=sys.stderr)
143-
sys.exit(1)
140+
# Determine source branch
141+
if args.branch:
142+
source_branch = args.branch
143+
else:
144+
# Get current branch from git
145+
try:
146+
result = subprocess.run(
147+
["git", "branch", "--show-current"],
148+
capture_output=True,
149+
text=True,
150+
check=True,
151+
)
152+
source_branch = result.stdout.strip()
153+
print(f"Using current branch: {source_branch}")
154+
except subprocess.CalledProcessError:
155+
print("ERROR: Could not determine current branch", file=sys.stderr)
156+
sys.exit(1)
144157

145-
if not repo_name:
146-
print("ERROR: GITHUB_REPOSITORY environment variable not set", file=sys.stderr)
147-
sys.exit(1)
158+
is_pr = args.event == "pull_request" if args.event else False
159+
160+
# GitHub integration is optional
161+
github_mode = bool(token and repo_name)
148162

149-
# Initialize GitHub API
150-
gh = Github(token)
151-
repo = gh.get_repo(repo_name)
163+
if github_mode:
164+
# Type narrowing: when github_mode is True, both token and repo_name are not None
165+
assert token is not None
166+
assert repo_name is not None
167+
print(f"GitHub mode: enabled (repo: {repo_name})")
168+
# Initialize GitHub API
169+
gh = Github(token)
170+
repo = gh.get_repo(repo_name)
152171

153-
# Check for existing PR (skip for pull_request events)
154-
if not is_pr:
155-
release_branch, existing_pr_number = check_existing_pr(repo, source_branch)
172+
# Check for existing PR (skip for pull_request events)
173+
if not is_pr:
174+
release_branch, existing_pr_number = check_existing_pr(repo, source_branch)
175+
else:
176+
release_branch = f"release/{source_branch}"
177+
existing_pr_number = None
178+
print(
179+
f"[PR VALIDATION MODE] Validating release for branch: {source_branch}"
180+
)
156181
else:
182+
print("GitHub mode: disabled (missing GITHUB_TOKEN or GITHUB_REPOSITORY)")
157183
release_branch = f"release/{source_branch}"
158184
existing_pr_number = None
159-
print(f"[PR VALIDATION MODE] Validating release for branch: {source_branch}")
160185

161186
repo_root = Path.cwd()
162187
projects = {
@@ -179,12 +204,13 @@ def main() -> None:
179204
if not any(to_release.values()):
180205
print("No changelog fragments found in any project, skipping release")
181206

182-
# Write GitHub Step Summary
183-
github_summary = os.environ.get("GITHUB_STEP_SUMMARY")
184-
if github_summary:
185-
with open(github_summary, "a") as f:
186-
f.write("## Release Proposal\n\n")
187-
f.write("ℹ️ No changelog fragments to process\n")
207+
# Write GitHub Step Summary (if in GitHub mode)
208+
if github_mode:
209+
github_summary = os.environ.get("GITHUB_STEP_SUMMARY")
210+
if github_summary:
211+
with open(github_summary, "a") as f:
212+
f.write("## Release Proposal\n\n")
213+
f.write("ℹ️ No changelog fragments to process\n")
188214

189215
sys.exit(0)
190216

@@ -210,8 +236,8 @@ def main() -> None:
210236

211237
print(f"{project_name} next version: {version}")
212238

213-
# Run towncrier
214-
if not run_towncrier(project_dir, version):
239+
# Run towncrier (draft mode for local runs)
240+
if not run_towncrier(project_dir, version, draft=not github_mode):
215241
print(f"ERROR: Towncrier build failed for {project_name}", file=sys.stderr)
216242
sys.exit(1)
217243

@@ -225,17 +251,18 @@ def main() -> None:
225251
releases_str = ", ".join(releases)
226252
print(f"\nSuccessfully prepared releases: {releases_str}")
227253

228-
# Write GitHub Actions outputs
229-
github_output = os.environ.get("GITHUB_OUTPUT")
230-
if github_output:
231-
with open(github_output, "a") as f:
232-
f.write(f"release_branch={release_branch}\n")
233-
f.write(f"releases={releases_str}\n")
234-
f.write(f"labels={','.join(labels)}\n")
254+
# Write GitHub Actions outputs (if in GitHub mode)
255+
if github_mode:
256+
github_output = os.environ.get("GITHUB_OUTPUT")
257+
if github_output:
258+
with open(github_output, "a") as f:
259+
f.write(f"release_branch={release_branch}\n")
260+
f.write(f"releases={releases_str}\n")
261+
f.write(f"labels={','.join(labels)}\n")
235262

236-
# Prepare PR content for workflow to use
237-
pr_title = f"Release: {releases_str}"
238-
pr_body = f"""## Release Proposal
263+
# Prepare PR content for workflow to use
264+
pr_title = f"Release: {releases_str}"
265+
pr_body = f"""## Release Proposal
239266
240267
This PR prepares the following releases:
241268
{releases_str}
@@ -253,39 +280,43 @@ def main() -> None:
253280
254281
**Merging this PR will automatically create tags and trigger PyPI uploads.**"""
255282

256-
# Write outputs for workflow
257-
if github_output:
258-
with open(github_output, "a") as f:
259-
# Write PR metadata (multiline strings need special encoding)
260-
f.write(f"pr_title={pr_title}\n")
261-
# For multiline, use GitHub Actions multiline syntax
262-
f.write(f"pr_body<<EOF\n{pr_body}\nEOF\n")
263-
# Check if PR exists
264-
if not is_pr:
265-
f.write(f"pr_exists={'true' if existing_pr_number else 'false'}\n")
266-
f.write(f"pr_number={existing_pr_number or ''}\n")
267-
268-
# Write GitHub Step Summary
269-
github_summary = os.environ.get("GITHUB_STEP_SUMMARY")
270-
if github_summary:
271-
with open(github_summary, "a") as f:
272-
if is_pr:
273-
f.write("## Release Proposal Validation\n\n")
274-
f.write("✅ **Status:** Validated successfully\n\n")
275-
f.write(f"**Planned Releases:** {releases_str}\n")
276-
else:
277-
f.write("## Release Proposal\n\n")
278-
f.write(f"**Releases:** {releases_str}\n")
279-
280-
# For PR validation, we're done
281-
if is_pr:
282-
print(f"\n[PR VALIDATION] Release validation successful: {releases_str}")
283-
return
284-
285-
# For push events, output success but don't create PR yet
286-
# (workflow will create PR after pushing the branch)
287-
print(f"\n[PUSH] Release preparation complete: {releases_str}")
288-
print("[PUSH] Workflow will commit, push branch, and create/update PR")
283+
# Write outputs for workflow
284+
if github_output:
285+
with open(github_output, "a") as f:
286+
# Write PR metadata (multiline strings need special encoding)
287+
f.write(f"pr_title={pr_title}\n")
288+
# For multiline, use GitHub Actions multiline syntax
289+
f.write(f"pr_body<<EOF\n{pr_body}\nEOF\n")
290+
# Check if PR exists
291+
if not is_pr:
292+
f.write(f"pr_exists={'true' if existing_pr_number else 'false'}\n")
293+
f.write(f"pr_number={existing_pr_number or ''}\n")
294+
295+
# Write GitHub Step Summary
296+
github_summary = os.environ.get("GITHUB_STEP_SUMMARY")
297+
if github_summary:
298+
with open(github_summary, "a") as f:
299+
if is_pr:
300+
f.write("## Release Proposal Validation\n\n")
301+
f.write("✅ **Status:** Validated successfully\n\n")
302+
f.write(f"**Planned Releases:** {releases_str}\n")
303+
else:
304+
f.write("## Release Proposal\n\n")
305+
f.write(f"**Releases:** {releases_str}\n")
306+
307+
# For PR validation, we're done
308+
if is_pr:
309+
print(f"\n[PR VALIDATION] Release validation successful: {releases_str}")
310+
return
311+
312+
# For push events, output success but don't create PR yet
313+
# (workflow will create PR after pushing the branch)
314+
print(f"\n[PUSH] Release preparation complete: {releases_str}")
315+
print("[PUSH] Workflow will commit, push branch, and create/update PR")
316+
else:
317+
# Local mode - just report what would be released
318+
print(f"\n[LOCAL MODE] Release proposal ready: {releases_str}")
319+
print("[LOCAL MODE] Review changes in CHANGELOG.md and commit manually")
289320

290321

291322
if __name__ == "__main__":

vcs-versioning/pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ no-cov = "cov --no-cov {args}"
9797
[[tool.hatch.envs.test.matrix]]
9898
python = [ "38", "39", "310", "311", "312", "313" ]
9999

100+
[tool.vcs-versioning]
101+
root = ".."
102+
version_scheme = "towncrier-fragments"
103+
tag_regex = "^vcs-versioning-(?P<version>v?\\d+(?:\\.\\d+){0,2}[^\\+]*)(?:\\+.*)?$"
104+
fallback_version = "0.1.0"
105+
scm.git.describe_command = ["git", "describe", "--dirty", "--tags", "--long", "--match", "vcs-versioning-*"]
106+
100107
[tool.coverage.run]
101108
branch = true
102109
parallel = true

vcs-versioning/src/vcs_versioning/_version_schemes_towncrier.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,21 @@ def version_from_fragments(version: ScmVersion) -> str:
109109
if version.exact:
110110
return version.format_with("{tag}")
111111

112-
# Try to find the root directory (where changelog.d/ should be)
113-
# The config object should have the root
114-
root = Path(version.config.absolute_root)
112+
# Find where to look for changelog.d/ directory
113+
# Prefer relative_to (location of config file) over fallback_root
114+
# This allows monorepo support where changelog.d/ is in the project dir
115+
if version.config.relative_to:
116+
# relative_to is typically the pyproject.toml file path
117+
# changelog.d/ should be in the same directory
118+
import os
119+
120+
if os.path.isfile(version.config.relative_to):
121+
root = Path(os.path.dirname(version.config.relative_to))
122+
else:
123+
root = Path(version.config.relative_to)
124+
else:
125+
# Fall back to using fallback_root if set, otherwise absolute_root
126+
root = Path(version.config.fallback_root or version.config.absolute_root)
115127

116128
log.debug("Analyzing fragments in %s", root)
117129

0 commit comments

Comments
 (0)