diff --git a/.github/workflows/branch-sync.yml b/.github/workflows/branch-sync.yml new file mode 100644 index 00000000..2ae17107 --- /dev/null +++ b/.github/workflows/branch-sync.yml @@ -0,0 +1,361 @@ +name: Branch Synchronization + +# Synchronize branches after successful releases: +# - Triggers after upload jobs complete +# - Verifies all expected releases for the commit succeeded +# - Creates sync PRs between branches + +on: + workflow_run: + workflows: ["python tests+artifacts+release"] + types: [completed] + branches: [main, develop] + +permissions: + contents: write + pull-requests: write + +jobs: + # Verify all releases completed and sync branches + sync-after-release: + # Only run if the triggering workflow succeeded and was triggered by a tag push + if: | + github.event.workflow_run.conclusion == 'success' && + startsWith(github.event.workflow_run.head_branch, 'setuptools-scm-v') || + startsWith(github.event.workflow_run.head_branch, 'vcs-versioning-v') + runs-on: ubuntu-latest + steps: + - name: Verify releases and create sync PR + uses: actions/github-script@v8 + with: + script: | + const workflowRun = context.payload.workflow_run; + const headSha = workflowRun.head_sha; + const headBranch = workflowRun.head_branch; + + console.log(`Workflow completed for: ${headBranch} at ${headSha}`); + + // Get all tags pointing to this commit + const { data: tagsResponse } = await github.rest.repos.listTags({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100 + }); + + const commitTags = tagsResponse.filter(tag => tag.commit.sha === headSha); + console.log(`Tags at commit ${headSha}: ${commitTags.map(t => t.name).join(', ') || 'none'}`); + + if (commitTags.length === 0) { + console.log('No tags found at this commit, nothing to sync'); + return; + } + + // Check which packages have tags at this commit + const setupToolsTag = commitTags.find(t => t.name.startsWith('setuptools-scm-v')); + const vcsVersioningTag = commitTags.find(t => t.name.startsWith('vcs-versioning-v')); + + console.log(`setuptools-scm tag: ${setupToolsTag?.name || 'none'}`); + console.log(`vcs-versioning tag: ${vcsVersioningTag?.name || 'none'}`); + + // Verify all expected releases have GitHub releases (created after successful upload) + const releasesToVerify = []; + if (setupToolsTag) releasesToVerify.push(setupToolsTag.name); + if (vcsVersioningTag) releasesToVerify.push(vcsVersioningTag.name); + + for (const tagName of releasesToVerify) { + try { + const { data: release } = await github.rest.repos.getReleaseByTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag: tagName + }); + console.log(`✓ Release exists for ${tagName}: ${release.html_url}`); + } catch (error) { + if (error.status === 404) { + console.log(`✗ Release not found for ${tagName}, waiting for all releases to complete`); + return; + } + throw error; + } + } + + console.log('All expected releases verified successfully'); + + // Determine which branch this commit is on + // Check if this commit is on develop (for develop→main sync) + let isDevelopRelease = false; + try { + const { data: developBranch } = await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch: 'develop' + }); + + // Check if this commit is an ancestor of develop + const { data: comparison } = await github.rest.repos.compareCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + base: headSha, + head: 'develop' + }); + + // If develop is at or ahead of this commit, it's a develop release + isDevelopRelease = comparison.status === 'identical' || comparison.status === 'ahead'; + console.log(`Commit is on develop: ${isDevelopRelease}`); + } catch (error) { + if (error.status === 404) { + console.log('develop branch does not exist'); + } else { + throw error; + } + } + + if (!isDevelopRelease) { + console.log('This is a main branch release, no sync needed (main→develop sync happens on PR merge)'); + return; + } + + // For develop releases, create sync PR to main + console.log('Creating sync PR from develop release to main'); + + // Use short commit SHA for branch name (simple and unique) + const tempBranchName = `sync/develop-to-main-${headSha.substring(0, 8)}`; + + console.log(`Creating temporary branch ${tempBranchName} from commit ${headSha}`); + + // Check if the commit has changes compared to main + const mainComparison = await github.rest.repos.compareCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + base: 'main', + head: headSha + }); + + if (mainComparison.data.ahead_by === 0) { + console.log('Commit has no new changes for main, skipping'); + return; + } + + console.log(`Commit has ${mainComparison.data.ahead_by} commits not on main`); + + // Check for existing sync PR + const existingPRs = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.repo.owner}:${tempBranchName}`, + base: 'main' + }); + + if (existingPRs.data.length > 0) { + console.log(`Sync PR already exists: #${existingPRs.data[0].number}`); + return; + } + + // Create temporary branch from the exact commit SHA + try { + await github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `refs/heads/${tempBranchName}`, + sha: headSha + }); + console.log(`Created temporary branch ${tempBranchName}`); + } catch (error) { + if (error.status === 422) { + console.log(`Branch ${tempBranchName} already exists`); + } else { + throw error; + } + } + + // Build release info for PR body + const releaseInfo = releasesToVerify.map(tag => `- \`${tag}\``).join('\n'); + + // Build PR body + const body = [ + '## Branch Synchronization', + '', + 'This PR syncs the release from `develop` to `main`.', + '', + '**Released tags:**', + releaseInfo, + '', + `**Commit:** ${headSha}`, + `**Temporary branch:** \`${tempBranchName}\``, + '', + 'This PR uses a temporary branch created from the exact release commit', + 'to ensure only the release changes are included (no extra commits).', + '', + 'All PyPI uploads have been verified successful before creating this PR.', + '', + 'This is an automated PR created by the branch-sync workflow.', + 'If there are no conflicts, this PR will be auto-merged.', + '', + 'The temporary branch will be deleted when this PR is merged or closed.' + ].join('\n'); + + // Build title with tag names + const title = `Sync: develop → main (${releasesToVerify.join(', ')})`; + + // Create new sync PR from the temporary branch + const { data: pr } = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + head: tempBranchName, + base: 'main' + }); + + console.log(`Created sync PR #${pr.number}`); + + // Add labels + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: ['sync', 'auto-merge'] + }); + + // Try to enable auto-merge + try { + await github.graphql(` + mutation($pullRequestId: ID!) { + enablePullRequestAutoMerge(input: {pullRequestId: $pullRequestId, mergeMethod: MERGE}) { + pullRequest { + autoMergeRequest { + enabledAt + } + } + } + } + `, { + pullRequestId: pr.node_id + }); + console.log('Auto-merge enabled'); + } catch (error) { + console.log('Could not enable auto-merge (may require branch protection rules):', error.message); + } + + # Forward-port: main → develop + # When a release PR is merged to main, create PR to keep develop in sync + sync-main-to-develop: + if: | + github.event.workflow_run.conclusion == 'success' && + (startsWith(github.event.workflow_run.head_branch, 'setuptools-scm-v') || + startsWith(github.event.workflow_run.head_branch, 'vcs-versioning-v')) + runs-on: ubuntu-latest + steps: + - name: Create PR main → develop if needed + uses: actions/github-script@v8 + with: + script: | + const workflowRun = context.payload.workflow_run; + const headSha = workflowRun.head_sha; + + // Check if develop branch exists + let developExists = true; + try { + await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch: 'develop' + }); + } catch (error) { + if (error.status === 404) { + console.log('develop branch does not exist, skipping'); + developExists = false; + } else { + throw error; + } + } + + if (!developExists) return; + + // Check if this commit is on main but not on develop + const { data: mainBranch } = await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch: 'main' + }); + + // Check if commit is an ancestor of main + let isMainRelease = false; + try { + const { data: comparison } = await github.rest.repos.compareCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + base: headSha, + head: 'main' + }); + isMainRelease = comparison.status === 'identical' || comparison.status === 'ahead'; + } catch { + isMainRelease = false; + } + + if (!isMainRelease) { + console.log('This is not a main branch release, skipping main→develop sync'); + return; + } + + // Check if main has commits that develop doesn't have + const comparison = await github.rest.repos.compareCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + base: 'develop', + head: 'main' + }); + + if (comparison.data.ahead_by === 0) { + console.log('main has no new commits for develop, skipping'); + return; + } + + console.log(`main has ${comparison.data.ahead_by} commits not on develop`); + + // Check for existing sync PR + const existingPRs = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.repo.owner}:main`, + base: 'develop' + }); + + if (existingPRs.data.length > 0) { + console.log(`Sync PR already exists: #${existingPRs.data[0].number}`); + return; + } + + // Build PR body + const body = [ + '## Branch Synchronization', + '', + 'This PR syncs the release from `main` to `develop`.', + '', + `**Commit:** ${headSha}`, + '', + 'This is an automated PR created by the branch-sync workflow.', + 'Review and merge to keep `develop` up to date with `main`.' + ].join('\n'); + + // Create new sync PR + const { data: pr } = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Sync: main → develop`, + body: body, + head: 'main', + base: 'develop' + }); + + console.log(`Created sync PR #${pr.number}`); + + // Add label + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: ['sync'] + }); diff --git a/.github/workflows/create-release-tags.yml b/.github/workflows/create-release-tags.yml index 66dff040..1672916f 100644 --- a/.github/workflows/create-release-tags.yml +++ b/.github/workflows/create-release-tags.yml @@ -5,6 +5,7 @@ on: types: [closed] branches: - main + - develop permissions: contents: write @@ -18,128 +19,140 @@ jobs: contains(github.event.pull_request.labels.*.name, 'release:vcs-versioning')) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - name: Create tags and releases + uses: actions/github-script@v8 with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.merge_commit_sha }} - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.11' - - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Create tags - id: create-tags - run: | - set -e - - TAGS_CREATED="" - PR_TITLE="${{ github.event.pull_request.title }}" - - # Check if we should release setuptools-scm - if echo "${{ toJson(github.event.pull_request.labels.*.name) }}" | grep -q "release:setuptools-scm"; then - # Extract version from PR title: "Release: setuptools-scm v9.3.0, ..." - VERSION=$(echo "$PR_TITLE" | grep -oP 'setuptools-scm v\K[0-9]+\.[0-9]+\.[0-9]+') - - if [ -z "$VERSION" ]; then - echo "ERROR: Failed to extract setuptools-scm version from PR title" - echo "PR title: $PR_TITLE" - echo "Expected format: 'Release: setuptools-scm vX.Y.Z'" - exit 1 - fi - - TAG="setuptools-scm-v$VERSION" - echo "Creating tag: $TAG" - - git tag -a "$TAG" -m "Release setuptools-scm v$VERSION" - git push origin "$TAG" - - TAGS_CREATED="$TAGS_CREATED $TAG" - echo "setuptools_scm_tag=$TAG" >> $GITHUB_OUTPUT - echo "setuptools_scm_version=$VERSION" >> $GITHUB_OUTPUT - fi - - # Check if we should release vcs-versioning - if echo "${{ toJson(github.event.pull_request.labels.*.name) }}" | grep -q "release:vcs-versioning"; then - # Extract version from PR title: "Release: ..., vcs-versioning v0.2.0" - VERSION=$(echo "$PR_TITLE" | grep -oP 'vcs-versioning v\K[0-9]+\.[0-9]+\.[0-9]+') - - if [ -z "$VERSION" ]; then - echo "ERROR: Failed to extract vcs-versioning version from PR title" - echo "PR title: $PR_TITLE" - echo "Expected format: 'Release: vcs-versioning vX.Y.Z'" - exit 1 - fi - - TAG="vcs-versioning-v$VERSION" - echo "Creating tag: $TAG" - - git tag -a "$TAG" -m "Release vcs-versioning v$VERSION" - git push origin "$TAG" - - TAGS_CREATED="$TAGS_CREATED $TAG" - echo "vcs_versioning_tag=$TAG" >> $GITHUB_OUTPUT - echo "vcs_versioning_version=$VERSION" >> $GITHUB_OUTPUT - fi - - echo "tags_created=$TAGS_CREATED" >> $GITHUB_OUTPUT - - - name: Extract changelog for setuptools-scm - if: steps.create-tags.outputs.setuptools_scm_version - id: changelog-setuptools-scm - run: | - VERSION="${{ steps.create-tags.outputs.setuptools_scm_version }}" - cd setuptools-scm - - # Extract the changelog section for this version - # Read from version heading until next version heading or EOF - CHANGELOG=$(awk "/^## $VERSION/,/^## [0-9]/" CHANGELOG.md | sed '1d;$d') - - # Save to file for GitHub release - echo "$CHANGELOG" > /tmp/changelog-setuptools-scm.md - - - name: Extract changelog for vcs-versioning - if: steps.create-tags.outputs.vcs_versioning_version - id: changelog-vcs-versioning - run: | - VERSION="${{ steps.create-tags.outputs.vcs_versioning_version }}" - cd vcs-versioning - - # Extract the changelog section for this version - CHANGELOG=$(awk "/^## $VERSION/,/^## [0-9]/" CHANGELOG.md | sed '1d;$d') - - # Save to file for GitHub release - echo "$CHANGELOG" > /tmp/changelog-vcs-versioning.md - - - name: Create GitHub Release for setuptools-scm - if: steps.create-tags.outputs.setuptools_scm_tag - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.create-tags.outputs.setuptools_scm_tag }} - name: setuptools-scm v${{ steps.create-tags.outputs.setuptools_scm_version }} - body_path: /tmp/changelog-setuptools-scm.md - draft: false - prerelease: false - - - name: Create GitHub Release for vcs-versioning - if: steps.create-tags.outputs.vcs_versioning_tag - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.create-tags.outputs.vcs_versioning_tag }} - name: vcs-versioning v${{ steps.create-tags.outputs.vcs_versioning_version }} - body_path: /tmp/changelog-vcs-versioning.md - draft: false - prerelease: false - - - name: Summary - run: | - echo "## Tags Created" >> $GITHUB_STEP_SUMMARY - echo "${{ steps.create-tags.outputs.tags_created }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "PyPI upload will be triggered automatically by tag push." >> $GITHUB_STEP_SUMMARY - + script: | + const pr = context.payload.pull_request; + const prTitle = pr.title; + const mergeCommitSha = pr.merge_commit_sha; + const labels = pr.labels.map(l => l.name); + + console.log(`Processing PR #${pr.number}: ${prTitle}`); + console.log(`Merge commit: ${mergeCommitSha}`); + console.log(`Labels: ${labels.join(', ')}`); + + const tagsCreated = []; + + // Helper to extract version from PR title + function extractVersion(title, packageName) { + const regex = new RegExp(`${packageName} v(\\d+\\.\\d+\\.\\d+)`); + const match = title.match(regex); + return match ? match[1] : null; + } + + // Helper to extract changelog section for a version + async function extractChangelog(packageDir, version) { + try { + const { data: file } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: `${packageDir}/CHANGELOG.md`, + ref: mergeCommitSha + }); + + const content = Buffer.from(file.content, 'base64').toString('utf-8'); + const lines = content.split('\n'); + + let inSection = false; + let changelog = []; + + for (const line of lines) { + if (line.startsWith(`## ${version}`)) { + inSection = true; + continue; // Skip the header line + } + if (inSection && line.match(/^## \d/)) { + break; // Next version section + } + if (inSection) { + changelog.push(line); + } + } + + // Trim leading/trailing empty lines + while (changelog.length > 0 && changelog[0].trim() === '') { + changelog.shift(); + } + while (changelog.length > 0 && changelog[changelog.length - 1].trim() === '') { + changelog.pop(); + } + + return changelog.join('\n'); + } catch (error) { + console.log(`Could not extract changelog: ${error.message}`); + return `Release ${version}`; + } + } + + // Helper to create tag and release + async function createTagAndRelease(packageName, packageDir, tagPrefix) { + const version = extractVersion(prTitle, packageName); + if (!version) { + throw new Error(`Failed to extract ${packageName} version from PR title: ${prTitle}`); + } + + const tagName = `${tagPrefix}-v${version}`; + console.log(`Creating tag: ${tagName} at ${mergeCommitSha}`); + + // Create annotated tag via API + const { data: tagObject } = await github.rest.git.createTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag: tagName, + message: `Release ${packageName} v${version}`, + object: mergeCommitSha, + type: 'commit', + tagger: { + name: 'github-actions[bot]', + email: 'github-actions[bot]@users.noreply.github.com', + date: new Date().toISOString() + } + }); + + // Create ref for the tag + await github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `refs/tags/${tagName}`, + sha: tagObject.sha + }); + + console.log(`Tag ${tagName} created`); + tagsCreated.push(tagName); + + // Extract changelog + const changelog = await extractChangelog(packageDir, version); + + // Create GitHub release + const { data: release } = await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: tagName, + name: `${packageName} v${version}`, + body: changelog, + draft: false, + prerelease: false + }); + + console.log(`Release created: ${release.html_url}`); + return { tagName, version }; + } + + // Process setuptools-scm + if (labels.includes('release:setuptools-scm')) { + console.log('\n--- Processing setuptools-scm ---'); + await createTagAndRelease('setuptools-scm', 'setuptools-scm', 'setuptools-scm'); + } + + // Process vcs-versioning + if (labels.includes('release:vcs-versioning')) { + console.log('\n--- Processing vcs-versioning ---'); + await createTagAndRelease('vcs-versioning', 'vcs-versioning', 'vcs-versioning'); + } + + // Write summary + const summary = `## Tags Created\n\n${tagsCreated.map(t => `- \`${t}\``).join('\n')}\n\nPyPI upload will be triggered automatically by tag push.`; + await core.summary.addRaw(summary).write(); + + console.log(`\nDone! Created tags: ${tagsCreated.join(', ')}`); diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 80886847..ad03707d 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -6,7 +6,8 @@ on: branches: - "*" tags: - - "v*" + - "setuptools-scm-v*" + - "vcs-versioning-v*" release: types: [published] diff --git a/.github/workflows/release-proposal.yml b/.github/workflows/release-proposal.yml index ccaca734..03e1433a 100644 --- a/.github/workflows/release-proposal.yml +++ b/.github/workflows/release-proposal.yml @@ -68,6 +68,7 @@ jobs: uses: actions/github-script@v8 env: RELEASE_BRANCH: ${{ steps.release.outputs.release_branch }} + PR_BASE: ${{ steps.release.outputs.pr_base }} PR_EXISTS: ${{ steps.release.outputs.pr_exists }} PR_NUMBER: ${{ steps.release.outputs.pr_number }} PR_TITLE: ${{ steps.release.outputs.pr_title }} @@ -76,6 +77,7 @@ jobs: with: script: | const releaseBranch = process.env.RELEASE_BRANCH; + const prBase = process.env.PR_BASE; const prExists = process.env.PR_EXISTS === 'true'; const prNumber = process.env.PR_NUMBER; const prTitle = process.env.PR_TITLE; @@ -83,25 +85,26 @@ jobs: const labels = process.env.LABELS.split(',').filter(l => l); if (prExists && prNumber) { - // Update existing PR - console.log(`Updating existing PR #${prNumber}`); + // Update existing PR (including base branch if it changed) + console.log(`Updating existing PR #${prNumber} to target ${prBase}`); await github.rest.pulls.update({ owner: context.repo.owner, repo: context.repo.repo, pull_number: parseInt(prNumber), title: prTitle, - body: prBody + body: prBody, + base: prBase }); } else { - // Create new PR - console.log('Creating new PR'); + // Create new PR - targets the same branch it came from + console.log(`Creating new PR targeting ${prBase}`); const { data: pr } = await github.rest.pulls.create({ owner: context.repo.owner, repo: context.repo.repo, title: prTitle, body: prBody, head: releaseBranch, - base: 'main' + base: prBase }); console.log(`Created PR #${pr.number}`); diff --git a/src/vcs_versioning_workspace/create_release_proposal.py b/src/vcs_versioning_workspace/create_release_proposal.py index 9c90148e..b448d1e5 100644 --- a/src/vcs_versioning_workspace/create_release_proposal.py +++ b/src/vcs_versioning_workspace/create_release_proposal.py @@ -14,6 +14,7 @@ _format_version, parse_version, ) +from vcs_versioning._version_schemes._towncrier import get_release_version def find_fragments(project_dir: Path) -> list[Path]: @@ -41,7 +42,10 @@ def find_fragments(project_dir: Path) -> list[Path]: def get_next_version(project_dir: Path, repo_root: Path) -> str | None: - """Get the next version for a project using vcs-versioning API.""" + """Get the next version for a project using vcs-versioning API. + + Uses get_release_version() to produce clean versions without .devN suffix. + """ try: # Load configuration from project's pyproject.toml # All project-specific settings (tag_regex, fallback_version, etc.) are in the config files @@ -55,8 +59,11 @@ def get_next_version(project_dir: Path, repo_root: Path) -> str | None: print(f"ERROR: Could not parse version for {project_dir}", file=sys.stderr) return None - # Format the version string - version_string = _format_version(scm_version) + # Use get_release_version for clean version (no .devN suffix) + version_string = get_release_version(scm_version) + if version_string is None: + # No fragments found, fall back to standard formatting + version_string = _format_version(scm_version) # Extract just the public version (X.Y.Z) return version_string.split("+")[0] # Remove local part if present @@ -105,8 +112,9 @@ def check_existing_pr(repo: Repository, source_branch: str) -> tuple[str, int | repo_owner = repo.owner.login try: + # PRs target the same branch they came from (main→main, develop→develop) pulls = repo.get_pulls( - state="open", base="main", head=f"{repo_owner}:{release_branch}" + state="open", base=source_branch, head=f"{repo_owner}:{release_branch}" ) for pr in pulls: @@ -259,6 +267,8 @@ def main() -> None: f.write(f"release_branch={release_branch}\n") f.write(f"releases={releases_str}\n") f.write(f"labels={','.join(labels)}\n") + # PR targets the same branch it came from + f.write(f"pr_base={source_branch}\n") # Prepare PR content for workflow to use pr_title = f"Release: {releases_str}" diff --git a/vcs-versioning/src/vcs_versioning/_version_schemes/_towncrier.py b/vcs-versioning/src/vcs_versioning/_version_schemes/_towncrier.py index f15a5e05..4ec9aca6 100644 --- a/vcs-versioning/src/vcs_versioning/_version_schemes/_towncrier.py +++ b/vcs-versioning/src/vcs_versioning/_version_schemes/_towncrier.py @@ -92,6 +92,40 @@ def _determine_bump_type(fragments: dict[str, list[str]]) -> str | None: return None +def _get_changelog_root(version: ScmVersion) -> Path: + """Get the root directory where changelog.d/ should be located. + + For monorepo support, prefers relative_to (config file location). + Falls back to absolute_root (VCS root). + """ + import os + + if version.config.relative_to: + # relative_to is typically the pyproject.toml file path + # changelog.d/ should be in the same directory + if os.path.isfile(version.config.relative_to): + return Path(os.path.dirname(version.config.relative_to)) + else: + return Path(version.config.relative_to) + else: + # When no relative_to is set, use absolute_root (the VCS root) + return Path(version.config.absolute_root) + + +def _guess_next_major(version: ScmVersion) -> str: + """Guess next major version (X+1.0.0) from current tag.""" + from .. import _modify_version + + tag_version = _modify_version.strip_local(str(version.tag)) + parts = tag_version.split(".") + if len(parts) >= 1: + major = int(parts[0].lstrip("v")) # Handle 'v' prefix + return f"{major + 1}.0.0" + # Fallback to bump_dev + bumped = _modify_version._bump_dev(tag_version) + return bumped if bumped is not None else f"{tag_version}.dev0" + + def version_from_fragments(version: ScmVersion) -> str: """Version scheme that determines version from towncrier fragments. @@ -107,22 +141,7 @@ def version_from_fragments(version: ScmVersion) -> str: if version.exact: return version.format_with("{tag}") - # Find where to look for changelog.d/ directory - # Prefer relative_to (location of config file) for monorepo support - # This allows changelog.d/ to be in the project dir rather than repo root - if version.config.relative_to: - # relative_to is typically the pyproject.toml file path - # changelog.d/ should be in the same directory - import os - - if os.path.isfile(version.config.relative_to): - root = Path(os.path.dirname(version.config.relative_to)) - else: - root = Path(version.config.relative_to) - else: - # When no relative_to is set, use absolute_root (the VCS root) - root = Path(version.config.absolute_root) - + root = _get_changelog_root(version) log.debug("Analyzing fragments in %s", root) # Find and analyze fragments @@ -137,29 +156,57 @@ def version_from_fragments(version: ScmVersion) -> str: # Determine the next version based on bump type if bump_type == "major": - # Major bump: increment major version, reset minor and patch to 0 - from .. import _modify_version - - def guess_next_major(v: ScmVersion) -> str: - tag_version = _modify_version.strip_local(str(v.tag)) - parts = tag_version.split(".") - if len(parts) >= 1: - major = int(parts[0].lstrip("v")) # Handle 'v' prefix - return f"{major + 1}.0.0" - # Fallback to bump_dev - bumped = _modify_version._bump_dev(tag_version) - return bumped if bumped is not None else f"{tag_version}.dev0" - - return version.format_next_version(guess_next_major) + return version.format_next_version(_guess_next_major) elif bump_type == "minor": - # Minor bump: use simplified semver with MINOR retention return version.format_next_version( guess_next_simple_semver, retain=SEMVER_MINOR ) else: # patch - # Patch bump: use simplified semver with PATCH retention return version.format_next_version( guess_next_simple_semver, retain=SEMVER_PATCH ) + + +def get_release_version(version: ScmVersion) -> str | None: + """Get clean release version from towncrier fragments (no .devN suffix). + + Unlike version_from_fragments(), this returns only the clean version + string (e.g., "10.0.0") without .devN suffix. Used by release tooling. + + Args: + version: ScmVersion object from VCS + + Returns: + Clean version string, or None if no fragments found + """ + # If we're exactly on a tag, return it + if version.exact: + return version.format_with("{tag}") + + root = _get_changelog_root(version) + log.debug("Analyzing fragments for release version in %s", root) + + fragments = _find_fragments(root) + bump_type = _determine_bump_type(fragments) + + if bump_type is None: + log.debug("No fragments found, cannot determine release version") + return None + + log.info("Determined release version bump type from fragments: %s", bump_type) + + # KEY DIFFERENCE: Use fmt="{guessed}" for clean version (no .devN) + if bump_type == "major": + return version.format_next_version(_guess_next_major, fmt="{guessed}") + + elif bump_type == "minor": + return version.format_next_version( + guess_next_simple_semver, fmt="{guessed}", retain=SEMVER_MINOR + ) + + else: # patch + return version.format_next_version( + guess_next_simple_semver, fmt="{guessed}", retain=SEMVER_PATCH + )