ci: implement semver-based PR title validation and auto-versioning#1077
ci: implement semver-based PR title validation and auto-versioning#1077jeffredodd merged 7 commits intomainfrom
Conversation
570bbe6 to
7d62273
Compare
serikjensen
left a comment
There was a problem hiding this comment.
Looking like a good update overall! left some questions about how this coexists or potentially replaces existing commit linting, and then some questions/suggestions around process for the auto generated changelog
| name: PR Title Check | ||
|
|
||
| on: | ||
| pull_request: | ||
| types: [opened, edited, synchronize, reopened] | ||
|
|
||
| permissions: | ||
| pull-requests: read | ||
|
|
||
| jobs: | ||
| validate-pr-title: | ||
| name: Validate PR Title | ||
| runs-on: | ||
| group: gusto-ubuntu-default | ||
| steps: | ||
| - name: Check PR title follows conventional commits | ||
| uses: actions/github-script@v7 | ||
| with: | ||
| script: | | ||
| const title = context.payload.pull_request.title; | ||
|
|
||
| // Conventional commit types that are allowed | ||
| const validTypes = [ | ||
| 'feat', | ||
| 'fix', | ||
| 'docs', | ||
| 'style', | ||
| 'refactor', | ||
| 'perf', | ||
| 'test', | ||
| 'build', | ||
| 'ci', | ||
| 'chore', | ||
| 'revert' | ||
| ]; | ||
|
|
||
| // Regex pattern for conventional commits | ||
| // Matches: type(optional-scope)!?: description | ||
| // Examples: feat: add feature, fix(SDK-123): bug fix, feat!: breaking change | ||
| const conventionalCommitRegex = new RegExp( | ||
| `^(${validTypes.join('|')})` + // type | ||
| `(\\([\\w\\-\\/,]+\\))?` + // optional scope in parentheses | ||
| `!?` + // optional breaking change indicator | ||
| `: .+$` // colon, space, and description | ||
| ); | ||
|
|
||
| if (!conventionalCommitRegex.test(title)) { | ||
| const errorMessage = ` | ||
| ❌ **PR title does not follow conventional commits format** | ||
|
|
||
| Your title: \`${title}\` | ||
|
|
||
| **Expected format:** \`type(optional-scope): description\` | ||
|
|
||
| **Valid types and their semver impact:** | ||
| | Type | Version Bump | Description | | ||
| |------|--------------|-------------| | ||
| | \`feat\` | MINOR | New features | | ||
| | \`fix\` | PATCH | Bug fixes | | ||
| | \`feat!\` or \`fix!\` | MAJOR | Breaking changes (note the \`!\`) | | ||
| | \`docs\` | none | Documentation | | ||
| | \`chore\` | none | Maintenance | | ||
| | \`refactor\` | none | Code refactoring | | ||
| | \`test\` | none | Tests | | ||
| | \`ci\` | none | CI changes | | ||
| | \`style\` | none | Code style | | ||
| | \`perf\` | none | Performance | | ||
| | \`build\` | none | Build system | | ||
| | \`revert\` | none | Reverts | | ||
|
|
||
| **Examples:** | ||
| - \`feat: add new component\` | ||
| - \`fix: resolve validation issue\` | ||
| - \`feat(SDK-123): add payroll alerts\` | ||
| - \`feat!: redesign JSX component props\` (breaking change) | ||
| - \`chore: update dependencies\` | ||
| `; | ||
|
|
||
| core.setFailed(errorMessage); | ||
| } else { | ||
| // Determine version bump type for informational purposes | ||
| // Parse the type from the validated title (requires space after colon) | ||
| const typeMatch = title.match(/^(feat|fix)(\([^)]+\))?(!)?:\s/); | ||
|
|
||
| let versionBump = 'none'; | ||
| if (typeMatch) { | ||
| const isBreaking = typeMatch[3] === '!'; | ||
| if (isBreaking) { | ||
| versionBump = 'MAJOR'; | ||
| } else if (typeMatch[1] === 'feat') { | ||
| versionBump = 'MINOR'; | ||
| } else if (typeMatch[1] === 'fix') { | ||
| versionBump = 'PATCH'; | ||
| } | ||
| } | ||
|
|
||
| console.log(`✅ PR title is valid: "${title}"`); | ||
| console.log(`📦 Version bump on merge: ${versionBump}`); | ||
| } |
There was a problem hiding this comment.
I know we have a commitlint check that is already running when you try to commit. Do we need to remove that in favor of this update? Just wanting to have consistency there and only defer to one rather than having two checks run on PR titles
There was a problem hiding this comment.
i do agree on keeping the commitlint if we can
.cursor/commands/create-pr.md
Outdated
| - `feat!: redesign JSX component props` → MAJOR bump | ||
| - `feat(SDK-123): add payroll alerts` → MINOR bump with ticket scope |
There was a problem hiding this comment.
Does this allow for both formats? ex. the feat(SDK-123): or feat: syntax? Wondering if we can disallow one for purposes of simplicity? thinking less optionality is likely preferable
| 1. Verify the current version in `package.json` is correct | ||
| 2. Verify `CHANGELOG.md` has been updated with recent changes |
There was a problem hiding this comment.
When is the changelog generated? Is this happening in the background and auto merging back into main? Or is this happening as an automatic step in the PR when the PR is opened?
I have had configurations in the past where a dedicated release commit is generated and then merged manually which triggers the release. that'd likely get cumbersome here every time someone merged. Thinking maybe it behaves similar to adapter docs changes and other artifacts where the corresponding changelog entry gets updated based on your commit title at the time of commit?
I think it's important to have some control over the generated changelog, especially when it comes to breaking changes so that we can make sure to provide a good migration guide
|
@serikjensen Should we disable the breaking change stuff for now if we want to stay pre 1.0 for a while longer? |
|
This pull request introduces two GitHub Actions workflow injection risks in .github/workflows/auto-version.yaml: a command injection via direct interpolation of the user-controlled PR title into a shell run block, and a code injection where an attacker-controlled package.json version string is inserted into a node -e snippet, both allowing arbitrary code execution on the runner. Both issues occur in the auto-version workflow and should be fixed by sanitizing or validating inputs and avoiding direct shell/JS interpolation of untrusted PR or package.json values.
Command Injection in
|
| Vulnerability | Command Injection |
|---|---|
| Description | The 'Skip updates' step in the .github/workflows/auto-version.yaml workflow uses direct interpolation of the user-controlled PR title ${{ github.event.pull_request.title }} into a shell run block. Because GitHub Actions performs literal string substitution for ${{ }} expressions before invoking the shell, an attacker can craft a PR title containing shell metacharacters (e.g., "; id #) to execute arbitrary commands. This specific step is triggered when a PR is merged with a title that does not follow the conventional commit format, as the parsing logic in conventionalCommits.js will return 'none' for both the version bump type and changelog section, satisfying the step's if condition. |
Code Injection in .github/workflows/auto-version.yaml
| Vulnerability | Code Injection |
|---|---|
| Description | The workflow in .github/workflows/auto-version.yaml is vulnerable to code injection because it uses shell interpolation to insert the $CURRENT_VERSION variable into a JavaScript snippet executed via node -e. The value of $CURRENT_VERSION is fetched directly from the version field of package.json using a helper script. Since an attacker can control the content of package.json in a pull request, they can craft a malicious version string that breaks out of the JavaScript string literal and executes arbitrary code. This code runs in the context of the GitHub Actions runner after the PR is merged, which in this workflow has contents: write permissions. |
embedded-react-sdk/.github/workflows/auto-version.yaml
Lines 77 to 80 in bc06dc1
All finding details can be found in the DryRun Security Dashboard.
There was a problem hiding this comment.
Pull request overview
This PR implements automated semantic versioning and changelog management based on conventional commit-formatted PR titles. The system validates PR titles on submission and automatically bumps package versions and updates the changelog when PRs are merged to main.
Changes:
- Added PR title validation workflow that enforces conventional commits format and displays expected version bumps
- Added auto-version workflow that bumps package.json and updates CHANGELOG.md on PR merge based on commit type
- Updated documentation (CONTRIBUTING.md, PR template, cursor commands) with semver mapping and automation details
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 19 comments.
Show a summary per file
| File | Description |
|---|---|
.github/workflows/pr-title-check.yaml |
New workflow that validates PR titles follow conventional commits and shows expected version bump |
.github/workflows/auto-version.yaml |
New workflow that automatically bumps version and updates changelog when PRs merge to main |
CONTRIBUTING.md |
Added semver mapping table, updated release process to document automation |
.github/PULL_REQUEST_TEMPLATE.md |
Added version bump guidance and examples in PR template comments |
.cursor/commands/release.md |
Updated release command documentation to reflect automated versioning |
.cursor/commands/create-pr.md |
Added comprehensive semver guidelines table for PR title selection |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
.github/workflows/auto-version.yaml
Outdated
| // Version section exists, add to appropriate subsection | ||
| if (changelog.includes(sectionHeader)) { | ||
| // Subsection exists, find it and add entry after header | ||
| // Match section header followed by entries | ||
| const sectionRegex = new RegExp('(' + sectionHeader.replace(/[.*+?^\${}()|[\]\\\\]/g, '\\\\$&') + ')\\n\\n'); | ||
| updatedChangelog = changelog.replace(sectionRegex, '$1\\n\\n' + entry + '\\n'); | ||
| } else { | ||
| // Subsection doesn't exist, add it after version header | ||
| updatedChangelog = changelog.replace( | ||
| versionHeader + '\\n', | ||
| versionHeader + '\\n\\n' + sectionHeader + '\\n\\n' + entry + '\\n' | ||
| ); | ||
| } |
There was a problem hiding this comment.
The changelog.includes(sectionHeader) check is too broad. If there's a "### Fixes" section in any version in the changelog (not just the current version), this will incorrectly match it. This could lead to adding entries to the wrong version section. The logic should search for the section header only within the current version's content, not the entire changelog.
| // Version section exists, add to appropriate subsection | |
| if (changelog.includes(sectionHeader)) { | |
| // Subsection exists, find it and add entry after header | |
| // Match section header followed by entries | |
| const sectionRegex = new RegExp('(' + sectionHeader.replace(/[.*+?^\${}()|[\]\\\\]/g, '\\\\$&') + ')\\n\\n'); | |
| updatedChangelog = changelog.replace(sectionRegex, '$1\\n\\n' + entry + '\\n'); | |
| } else { | |
| // Subsection doesn't exist, add it after version header | |
| updatedChangelog = changelog.replace( | |
| versionHeader + '\\n', | |
| versionHeader + '\\n\\n' + sectionHeader + '\\n\\n' + entry + '\\n' | |
| ); | |
| } | |
| // Version section exists, work only within that section | |
| const versionHeaderIndex = changelog.indexOf(versionHeader); | |
| const nextVersionHeaderIndex = changelog.indexOf('\\n## ', versionHeaderIndex + versionHeader.length); | |
| const beforeVersion = changelog.slice(0, versionHeaderIndex); | |
| const currentVersionBlock = nextVersionHeaderIndex === -1 | |
| ? changelog.slice(versionHeaderIndex) | |
| : changelog.slice(versionHeaderIndex, nextVersionHeaderIndex); | |
| const afterVersion = nextVersionHeaderIndex === -1 | |
| ? '' | |
| : changelog.slice(nextVersionHeaderIndex); | |
| // Helper to escape section header for use in RegExp | |
| function escapeForRegex(str) { | |
| return str.replace(/[.*+?^\${}()|[\]\\\\]/g, '\\\\$&'); | |
| } | |
| let updatedVersionBlock; | |
| if (currentVersionBlock.includes(sectionHeader)) { | |
| // Subsection exists in current version, add entry after header | |
| const escapedSectionHeader = escapeForRegex(sectionHeader); | |
| const sectionRegex = new RegExp('(' + escapedSectionHeader + ')\\n\\n'); | |
| updatedVersionBlock = currentVersionBlock.replace(sectionRegex, '$1\\n\\n' + entry + '\\n'); | |
| } else { | |
| // Subsection doesn't exist in current version, add it after version header | |
| updatedVersionBlock = currentVersionBlock.replace( | |
| versionHeader + '\\n', | |
| versionHeader + '\\n\\n' + sectionHeader + '\\n\\n' + entry + '\\n' | |
| ); | |
| } | |
| updatedChangelog = beforeVersion + updatedVersionBlock + afterVersion; |
.github/workflows/auto-version.yaml
Outdated
| const description = process.env.DESCRIPTION; | ||
| const currentVersion = '$CURRENT_VERSION'; | ||
|
|
||
| // Determine which section header to use | ||
| const sectionHeaders = { | ||
| 'breaking': '### Breaking Changes', | ||
| 'features': '### Features & Enhancements', | ||
| 'fixes': '### Fixes', | ||
| 'chores': '### Chores & Maintenance' | ||
| }; | ||
|
|
||
| const sectionHeader = sectionHeaders[section] || '### Chores & Maintenance'; | ||
| const entry = '- ' + description; |
There was a problem hiding this comment.
The PR_NUMBER environment variable is extracted and set but never used in the changelog entry. Consider adding the PR number to the changelog entry for traceability, for example: const entry = '- ' + description + ' (#' + prNumber + ')';. This would match common changelog conventions and make it easier to trace changes back to their PRs.
.github/workflows/auto-version.yaml
Outdated
| # Parse version components | ||
| IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" |
There was a problem hiding this comment.
The version parsing using IFS='.' read -r MAJOR MINOR PATCH assumes the version always has exactly three parts separated by dots. This will fail or produce incorrect results for versions with pre-release tags (e.g., "1.0.0-beta.1") or versions with fewer than three parts. Consider validating the version format first or using a more robust parsing method that handles edge cases.
| # Parse version components | |
| IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" | |
| # Parse version components (handle pre-release/build metadata and short forms) | |
| BASE_VERSION="${CURRENT_VERSION%%[-+]*}" | |
| IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE_VERSION" | |
| : "${MINOR:=0}" | |
| : "${PATCH:=0}" |
|
|
||
| on: | ||
| pull_request: | ||
| types: [opened, edited, synchronize, reopened] |
There was a problem hiding this comment.
The workflow runs on the synchronize event, which triggers when new commits are pushed to the PR branch. However, PR title validation doesn't need to rerun when commits are added unless the title changes. This causes unnecessary workflow runs. Consider removing synchronize from the trigger list and keeping only opened, edited, and reopened, which are the events where the title could actually change.
| types: [opened, edited, synchronize, reopened] | |
| types: [opened, edited, reopened] |
.github/workflows/auto-version.yaml
Outdated
| // Match section header followed by entries | ||
| const sectionRegex = new RegExp('(' + sectionHeader.replace(/[.*+?^\${}()|[\]\\\\]/g, '\\\\$&') + ')\\n\\n'); | ||
| updatedChangelog = changelog.replace(sectionRegex, '$1\\n\\n' + entry + '\\n'); |
There was a problem hiding this comment.
The regex pattern here matches the section header followed by exactly two newlines. This is fragile because it assumes there are always two newlines after the header. If the changelog format varies (e.g., has one newline or three newlines), the replacement will fail. The same issue exists on line 222. Consider using \\n+ to match one or more newlines, or adjust the replacement logic to be more robust.
| // Match section header followed by entries | |
| const sectionRegex = new RegExp('(' + sectionHeader.replace(/[.*+?^\${}()|[\]\\\\]/g, '\\\\$&') + ')\\n\\n'); | |
| updatedChangelog = changelog.replace(sectionRegex, '$1\\n\\n' + entry + '\\n'); | |
| // Match section header followed by one or more newlines and preserve them | |
| const sectionRegex = new RegExp('(' + sectionHeader.replace(/[.*+?^\${}()|[\]\\\\]/g, '\\\\$&') + ')(\\n+)'); | |
| updatedChangelog = changelog.replace(sectionRegex, '$1$2' + entry + '\\n'); |
.github/workflows/auto-version.yaml
Outdated
| if (changelog.includes(sectionHeader)) { | ||
| // Subsection exists, find it and add entry after header | ||
| // Match section header followed by entries | ||
| const sectionRegex = new RegExp('(' + sectionHeader.replace(/[.*+?^\${}()|[\]\\\\]/g, '\\\\$&') + ')\\n\\n'); |
There was a problem hiding this comment.
The regex escaping is incorrect. The pattern uses \\\\ which will produce double backslashes in the JavaScript string, causing the regex to fail. This should be a single backslash \\ to properly escape special regex characters. The same issue exists on line 222.
| const sectionRegex = new RegExp('(' + sectionHeader.replace(/[.*+?^\${}()|[\]\\\\]/g, '\\\\$&') + ')\\n\\n'); | |
| const sectionRegex = new RegExp('(' + sectionHeader.replace(/[.*+?^\${}()|[\]\\]/g, '\\$&') + ')\\n\\n'); |
.github/workflows/auto-version.yaml
Outdated
| // Version section exists | ||
| if (changelog.includes(sectionHeader)) { | ||
| // Subsection exists, find it and add entry after header | ||
| const sectionRegex = new RegExp('(' + sectionHeader.replace(/[.*+?^\${}()|[\]\\\\]/g, '\\\\$&') + ')\\n\\n'); |
There was a problem hiding this comment.
The regex escaping is incorrect. The pattern uses \\\\ which will produce double backslashes in the JavaScript string, causing the regex to fail. This should be a single backslash \\ to properly escape special regex characters. The same issue exists on line 156.
| const sectionRegex = new RegExp('(' + sectionHeader.replace(/[.*+?^\${}()|[\]\\\\]/g, '\\\\$&') + ')\\n\\n'); | |
| const sectionRegex = new RegExp('(' + sectionHeader.replace(/[.*+?^\${}()|[\]\\]/g, '\\$&') + ')\\n\\n'); |
.github/workflows/auto-version.yaml
Outdated
| }; | ||
|
|
||
| const sectionHeader = sectionHeaders[section] || '### Chores & Maintenance'; | ||
| const entry = '- ' + description; |
There was a problem hiding this comment.
The PR_NUMBER environment variable is extracted and set but never used in the changelog entry. Consider adding the PR number to the changelog entry for traceability, for example: const entry = '- ' + description + ' (#' + prNumber + ')';. This would match common changelog conventions and make it easier to trace changes back to their PRs.
| const entry = '- ' + description; | |
| const entry = '- ' + description + (prNumber ? ' (#' + prNumber + ')' : ''); |
.github/workflows/auto-version.yaml
Outdated
| - name: Checkout repository | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| fetch-depth: 0 | ||
| token: ${{ secrets.GITHUB_TOKEN }} | ||
|
|
||
| - name: Setup Node.js | ||
| uses: actions/setup-node@v4 | ||
| with: | ||
| node-version-file: '.nvmrc' | ||
|
|
||
| - name: Configure Git | ||
| run: | | ||
| git config user.name "github-actions[bot]" | ||
| git config user.email "github-actions[bot]@users.noreply.github.com" | ||
|
|
||
| - name: Determine version bump type and extract PR info | ||
| id: bump-type | ||
| uses: actions/github-script@v7 | ||
| with: | ||
| script: | | ||
| const title = context.payload.pull_request.title; | ||
| const prNumber = context.payload.pull_request.number; | ||
|
|
||
| let bumpType = 'none'; | ||
| let changelogSection = 'none'; | ||
| let description = ''; | ||
|
|
||
| // Parse the type from the title using regex to ensure exact match | ||
| // Requires colon followed by space (: ) to match valid conventional commits | ||
| const typeMatch = title.match(/^(\w+)(\([^)]+\))?(!)?:\s+(.+)$/); | ||
|
|
||
| if (typeMatch) { | ||
| const type = typeMatch[1]; | ||
| const isBreaking = typeMatch[3] === '!'; | ||
| description = typeMatch[4]; | ||
|
|
||
| if (isBreaking) { | ||
| bumpType = 'major'; | ||
| changelogSection = 'breaking'; | ||
| } else if (type === 'feat') { | ||
| bumpType = 'minor'; | ||
| changelogSection = 'features'; | ||
| } else if (type === 'fix') { | ||
| bumpType = 'patch'; | ||
| changelogSection = 'fixes'; | ||
| } else { | ||
| // Other types like chore, docs, refactor, etc. | ||
| changelogSection = 'chores'; | ||
| } | ||
| } | ||
|
|
||
| console.log(`PR title: "${title}"`); | ||
| console.log(`PR number: ${prNumber}`); | ||
| console.log(`Version bump type: ${bumpType}`); | ||
| console.log(`Changelog section: ${changelogSection}`); | ||
| console.log(`Description: ${description}`); | ||
|
|
||
| core.setOutput('type', bumpType); | ||
| core.setOutput('changelog_section', changelogSection); | ||
| core.setOutput('description', description); | ||
| core.setOutput('pr_number', prNumber); | ||
|
|
||
| - name: Bump version and update changelog | ||
| if: steps.bump-type.outputs.type != 'none' | ||
| env: | ||
| CHANGELOG_SECTION: ${{ steps.bump-type.outputs.changelog_section }} | ||
| DESCRIPTION: ${{ steps.bump-type.outputs.description }} | ||
| PR_NUMBER: ${{ steps.bump-type.outputs.pr_number }} | ||
| run: | | ||
| BUMP_TYPE="${{ steps.bump-type.outputs.type }}" | ||
| echo "Bumping version: $BUMP_TYPE" | ||
|
|
||
| # Read current version | ||
| CURRENT_VERSION=$(node -p "require('./package.json').version") | ||
| echo "Current version: $CURRENT_VERSION" | ||
|
|
||
| # Parse version components | ||
| IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" | ||
|
|
||
| # Calculate new version based on bump type | ||
| case $BUMP_TYPE in | ||
| major) | ||
| NEW_VERSION="$((MAJOR + 1)).0.0" | ||
| ;; | ||
| minor) | ||
| NEW_VERSION="$MAJOR.$((MINOR + 1)).0" | ||
| ;; | ||
| patch) | ||
| NEW_VERSION="$MAJOR.$MINOR.$((PATCH + 1))" | ||
| ;; | ||
| esac | ||
|
|
||
| echo "New version: $NEW_VERSION" | ||
|
|
||
| # Update package.json using Node.js to preserve formatting | ||
| node -e " | ||
| const fs = require('fs'); | ||
| const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); | ||
| pkg.version = '$NEW_VERSION'; | ||
| fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); | ||
| " | ||
|
|
||
| # Update package-lock.json | ||
| npm install --package-lock-only | ||
|
|
||
| # Update CHANGELOG.md | ||
| node -e " | ||
| const fs = require('fs'); | ||
| const changelog = fs.readFileSync('CHANGELOG.md', 'utf8'); | ||
| const section = process.env.CHANGELOG_SECTION; | ||
| const description = process.env.DESCRIPTION; | ||
| const prNumber = process.env.PR_NUMBER; | ||
| const newVersion = '$NEW_VERSION'; | ||
|
|
||
| // Determine which section header to use | ||
| const sectionHeaders = { | ||
| 'breaking': '### Breaking Changes', | ||
| 'features': '### Features & Enhancements', | ||
| 'fixes': '### Fixes', | ||
| 'chores': '### Chores & Maintenance' | ||
| }; | ||
|
|
||
| const sectionHeader = sectionHeaders[section] || '### Chores & Maintenance'; | ||
| const entry = '- ' + description; | ||
|
|
||
| // Check if the current version section exists | ||
| const versionHeader = '## ' + newVersion; | ||
|
|
||
| let updatedChangelog; | ||
|
|
||
| if (changelog.includes(versionHeader)) { | ||
| // Version section exists, add to appropriate subsection | ||
| if (changelog.includes(sectionHeader)) { | ||
| // Subsection exists, find it and add entry after header | ||
| // Match section header followed by entries | ||
| const sectionRegex = new RegExp('(' + sectionHeader.replace(/[.*+?^\${}()|[\]\\\\]/g, '\\\\$&') + ')\\n\\n'); | ||
| updatedChangelog = changelog.replace(sectionRegex, '$1\\n\\n' + entry + '\\n'); | ||
| } else { | ||
| // Subsection doesn't exist, add it after version header | ||
| updatedChangelog = changelog.replace( | ||
| versionHeader + '\\n', | ||
| versionHeader + '\\n\\n' + sectionHeader + '\\n\\n' + entry + '\\n' | ||
| ); | ||
| } | ||
| } else { | ||
| // Version section doesn't exist, create it | ||
| const newSection = versionHeader + '\\n\\n' + sectionHeader + '\\n\\n' + entry + '\\n'; | ||
| updatedChangelog = changelog.replace( | ||
| '# Changelog\\n', | ||
| '# Changelog\\n\\n' + newSection + '\\n' | ||
| ); | ||
| } | ||
|
|
||
| fs.writeFileSync('CHANGELOG.md', updatedChangelog); | ||
| console.log('Updated CHANGELOG.md with: ' + entry); | ||
| " | ||
|
|
||
| # Commit and push | ||
| git add package.json package-lock.json CHANGELOG.md | ||
| git commit -m "chore: bump version to $NEW_VERSION [skip ci]" | ||
| git push |
There was a problem hiding this comment.
If multiple PRs are merged in quick succession, there's a race condition where the second workflow may not see the changes from the first one. The checkout action at line 21 fetches the repository state at the time the PR was merged, but if another PR was merged while this workflow is running, the git push at line 181 could fail due to conflicts, or worse, overwrite the other workflow's changes. Consider using a concurrency group to serialize these workflows or implement a retry mechanism with pull-rebase logic.
.github/workflows/auto-version.yaml
Outdated
| # Commit and push | ||
| git add package.json package-lock.json CHANGELOG.md | ||
| git commit -m "chore: bump version to $NEW_VERSION [skip ci]" | ||
| git push |
There was a problem hiding this comment.
The workflow doesn't handle the case where the git push fails (e.g., due to branch protection rules, network issues, or merge conflicts). If the push fails, the version bump and changelog update are lost and there's no retry mechanism or notification. Consider adding error handling and notifications, or using a pull request approach instead of direct pushes to handle failures more gracefully.
| git push | |
| # Push with basic retry and clear error messaging | |
| max_retries=3 | |
| attempt=1 | |
| until git push; do | |
| echo "git push failed on attempt $attempt of $max_retries." | |
| if [ "$attempt" -ge "$max_retries" ]; then | |
| echo "git push failed after $max_retries attempts." | |
| echo "Possible causes include:" | |
| echo " - Branch protection rules preventing direct pushes to the target branch." | |
| echo " - Non-fast-forward updates due to new commits on the remote branch." | |
| echo " - Transient network or authentication issues." | |
| echo "" | |
| echo "The version bump and CHANGELOG updates have been committed in this workflow run," | |
| echo "but could not be pushed to the remote repository." | |
| echo "Please apply the version bump and changelog update manually, then push the changes." | |
| exit 1 | |
| fi | |
| attempt=$((attempt + 1)) | |
| echo "Retrying git push in 5 seconds..." | |
| sleep 5 | |
| done |
| git config user.name "github-actions[bot]" | ||
| git config user.email "github-actions[bot]@users.noreply.github.com" | ||
|
|
||
| - name: Determine version bump type and extract PR info |
There was a problem hiding this comment.
is there an opportunity to extract some of this logic into separate scripts to simplify this file a bit?
| } || { | ||
| echo "Changelog not updated" | ||
| } | ||
|
|
There was a problem hiding this comment.
Would it make sense to add Git Tags for versioning for easier tracking?
.github/workflows/auto-version.yaml
Outdated
| run: | | ||
| echo "No version bump needed, but updating changelog" | ||
|
|
||
| # Get current version for the changelog section |
There was a problem hiding this comment.
The version code is repeated in here as well as on line 93, is there an opportunity to reduce the dupe code by doing something like this?
BUMP_TYPE="${{ steps.bump-type.outputs.type }}"
CURRENT_VERSION=$(node -p "require('./package.json').version")
if [ "$BUMP_TYPE" != "none" ]; then
# Calculate NEW_VERSION logic (Lines 117-126)
# Write to package.json (Lines 128-135)
else
NEW_VERSION=$CURRENT_VERSION
fi
.github/workflows/auto-version.yaml
Outdated
|
|
||
| // Parse the type from the title using regex to ensure exact match | ||
| // Requires colon followed by space (: ) to match valid conventional commits | ||
| const typeMatch = title.match(/^(\w+)(\([^)]+\))?(!)?:\s+(.+)$/); |
There was a problem hiding this comment.
this regex is slightly different than the one used in pr-title-check.yaml; should we keep them consistent?
| // Regex pattern for conventional commits | ||
| // Matches: type(optional-scope)!?: description | ||
| // Examples: feat: add feature, fix(SDK-123): bug fix, feat!: breaking change | ||
| const conventionalCommitRegex = new RegExp( |
There was a problem hiding this comment.
would it be worth adding a check to ensure casing is correct? ie: feat vs FEAT
|
Summary of Changes
|
|
Summary of Changes
git tag -a "v$NEW_VERSION" -m "Release v$NEW_VERSION"
git push origin "v$NEW_VERSION"This makes it easier to track releases and see version history.
cc: @krisxcrash |
Co-authored-by: jeffredodd <2861516+jeffredodd@users.noreply.github.com>
…detection Co-authored-by: jeffredodd <2861516+jeffredodd@users.noreply.github.com>
Co-authored-by: jeffredodd <2861516+jeffredodd@users.noreply.github.com>
Co-authored-by: jeffredodd <2861516+jeffredodd@users.noreply.github.com>
Co-authored-by: jeffredodd <2861516+jeffredodd@users.noreply.github.com>
Co-authored-by: jeffredodd <2861516+jeffredodd@users.noreply.github.com>
Co-authored-by: jeffredodd <2861516+jeffredodd@users.noreply.github.com>
e15558a to
bc06dc1
Compare
Automates semantic versioning based on conventional commit PR titles. Version bumps, changelog updates, and git tags are created on merge to main.
New Workflows
pr-title-check.yaml: Validates PR titles match conventional commits format (enforces lowercase types)auto-version.yaml: On merge, bumpspackage.json, updatesCHANGELOG.md, creates git tag (v0.27.0, etc.)Version Mapping (0.x.x pre-release)
feat:fix:feat!:/fix!:chore:,docs:, etc.*Per semver spec, breaking changes bump MINOR during 0.x.x since API is unstable.
Testable Scripts
Extracted versioning logic into
build/versioning/with 35 unit tests:conventionalCommits.js- Parsing, validation, version calculationupdateChangelog.js- Changelog and package.json updatesCoexistence with Commitlint
Both remain active—they serve different purposes.
Documentation
Updated
CONTRIBUTING.md, PR template, and.cursor/commands/with semver guidance.💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.