Skip to content

ci: implement semver-based PR title validation and auto-versioning#1077

Merged
jeffredodd merged 7 commits intomainfrom
copilot/implement-semver-specification
Feb 13, 2026
Merged

ci: implement semver-based PR title validation and auto-versioning#1077
jeffredodd merged 7 commits intomainfrom
copilot/implement-semver-specification

Conversation

Copy link
Contributor

Copilot AI commented Feb 12, 2026

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, bumps package.json, updates CHANGELOG.md, creates git tag (v0.27.0, etc.)

Version Mapping (0.x.x pre-release)

PR Title Version Bump Changelog Section
feat: MINOR Features & Enhancements
fix: PATCH Fixes
feat!: / fix!: MINOR* Breaking Changes
chore:, docs:, etc. none Chores & Maintenance

*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:

npx vitest run build/versioning/conventionalCommits.test.js
  • conventionalCommits.js - Parsing, validation, version calculation
  • updateChangelog.js - Changelog and package.json updates

Coexistence with Commitlint

  • Commitlint (husky): Validates individual commits locally
  • PR Title Check (CI): Validates squash merge title for versioning

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.

Copilot AI changed the title [WIP] Implement semantic versioning support in embedded React SDK ci: implement semver-based PR title validation and auto-versioning Feb 12, 2026
Copilot AI requested a review from jeffredodd February 12, 2026 16:23
@jeffredodd jeffredodd force-pushed the copilot/implement-semver-specification branch from 570bbe6 to 7d62273 Compare February 12, 2026 16:25
Copilot AI changed the title ci: implement semver-based PR title validation and auto-versioning ci: implement semver-based PR title validation and auto-versioning with changelog Feb 12, 2026
Copilot AI changed the title ci: implement semver-based PR title validation and auto-versioning with changelog ci: implement semver-based PR title validation and auto-versioning Feb 12, 2026
@jeffredodd jeffredodd marked this pull request as ready for review February 12, 2026 22:25
Copilot AI review requested due to automatic review settings February 12, 2026 22:25
Copy link
Member

@serikjensen serikjensen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines 1 to 99
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}`);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i do agree on keeping the commitlint if we can

Comment on lines 41 to 42
- `feat!: redesign JSX component props` → MAJOR bump
- `feat(SDK-123): add payroll alerts` → MINOR bump with ticket scope
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +18 to +19
1. Verify the current version in `package.json` is correct
2. Verify `CHANGELOG.md` has been updated with recent changes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@jeffredodd
Copy link
Contributor

@serikjensen Should we disable the breaking change stuff for now if we want to stay pre 1.0 for a while longer?

@dryrunsecurity
Copy link

dryrunsecurity bot commented Feb 12, 2026

DryRun Security

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 .github/workflows/auto-version.yaml
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.

echo "PR title: ${{ github.event.pull_request.title }}"

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.

console.log(calculateNewVersion('$CURRENT_VERSION', '$BUMP_TYPE'));
")
echo "New version: $NEW_VERSION"


All finding details can be found in the DryRun Security Dashboard.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 152 to 164
// 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'
);
}
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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;

Copilot uses AI. Check for mistakes.
Comment on lines 200 to 212
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;
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 97 to 98
# Parse version components
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
# 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}"

Copilot uses AI. Check for mistakes.

on:
pull_request:
types: [opened, edited, synchronize, reopened]
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
types: [opened, edited, synchronize, reopened]
types: [opened, edited, reopened]

Copilot uses AI. Check for mistakes.
Comment on lines 155 to 157
// Match section header followed by entries
const sectionRegex = new RegExp('(' + sectionHeader.replace(/[.*+?^\${}()|[\]\\\\]/g, '\\\\$&') + ')\\n\\n');
updatedChangelog = changelog.replace(sectionRegex, '$1\\n\\n' + entry + '\\n');
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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');

Copilot uses AI. Check for mistakes.
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');
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const sectionRegex = new RegExp('(' + sectionHeader.replace(/[.*+?^\${}()|[\]\\\\]/g, '\\\\$&') + ')\\n\\n');
const sectionRegex = new RegExp('(' + sectionHeader.replace(/[.*+?^\${}()|[\]\\]/g, '\\$&') + ')\\n\\n');

Copilot uses AI. Check for mistakes.
// 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');
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const sectionRegex = new RegExp('(' + sectionHeader.replace(/[.*+?^\${}()|[\]\\\\]/g, '\\\\$&') + ')\\n\\n');
const sectionRegex = new RegExp('(' + sectionHeader.replace(/[.*+?^\${}()|[\]\\]/g, '\\$&') + ')\\n\\n');

Copilot uses AI. Check for mistakes.
};

const sectionHeader = sectionHeaders[section] || '### Chores & Maintenance';
const entry = '- ' + description;
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const entry = '- ' + description;
const entry = '- ' + description + (prNumber ? ' (#' + prNumber + ')' : '');

Copilot uses AI. Check for mistakes.
Comment on lines 20 to 181
- 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
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
# 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
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there an opportunity to extract some of this logic into separate scripts to simplify this file a bit?

} || {
echo "Changelog not updated"
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to add Git Tags for versioning for easier tracking?

run: |
echo "No version bump needed, but updating changelog"

# Get current version for the changelog section
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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


// 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+(.+)$/);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be worth adding a check to ensure casing is correct? ie: feat vs FEAT

@jeffredodd
Copy link
Contributor

jeffredodd commented Feb 12, 2026

Summary of Changes

  1. Fixed 0.x.x Semver Handling
    Per the semver specification, during 0.x.x (pre-1.0 development), breaking changes should bump MINOR instead of MAJOR. The API is considered unstable during this period. Updated:
    .github/workflows/auto-version.yaml - Breaking changes now bump MINOR
    .github/workflows/pr-title-check.yaml - Updated messaging to reflect 0.x.x behavior
    All documentation files

  2. Clarified Commitlint vs PR Title Check
    Added documentation explaining that both checks serve different purposes:
    Commitlint: Validates individual commit messages (via husky pre-commit hook)
    PR Title Check: Validates PR titles used for squash merges and version bumping

  3. Clarified Changelog Generation Timing
    Updated documentation to clarify that the changelog is generated on merge to main (not when PR is opened), and the workflow auto-commits changes with [skip ci].

  4. Documented Scope Format
    Both feat: and feat(SDK-123): are valid - the scope is optional and useful for Jira ticket linking.

@jeffredodd
Copy link
Contributor

Summary of Changes

  1. Consistent Regex Patterns ✅
    Both auto-version.yaml and pr-title-check.yaml now use the same approach:
    Define validTypes array with all valid conventional commit types
    Build regex dynamically from the array
    Both enforce lowercase types only

  2. Lowercase Type Validation ✅
    Added explicit check in pr-title-check.yaml that detects uppercase types (like FEAT:) and provides a helpful error message:
    Code
    ❌ PR title uses uppercase type - must be lowercase
    The type FEAT should be lowercase: feat

  3. Git Tags for Version Tracking ✅
    auto-version.yaml now creates annotated Git tags when bumping versions:

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.

  1. Reduced Duplicate Code ✅
    Consolidated the two separate steps ("Bump version and update changelog" and "Update changelog only") into a single step that handles both cases:
    Reads current version once
    Calculates new version if needed
    Updates changelog if needed
    Commits appropriate files based on what changed
    The workflow went from 256 lines to 205 lines while maintaining the same functionality.

  2. Script Extraction Decision 📝
    Kept the logic inline in the YAML files rather than extracting to separate scripts. Tradeoffs considered:
    Pro of inline: Easier to review in GitHub PR diffs, self-contained, no need to navigate between files
    Con of inline: More verbose YAML files
    Decision: The current size is manageable and the benefits of inline visibility outweigh extraction

cc: @krisxcrash

Copilot AI and others added 7 commits February 13, 2026 14:16
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>
@jeffredodd jeffredodd force-pushed the copilot/implement-semver-specification branch from e15558a to bc06dc1 Compare February 13, 2026 22:17
@jeffredodd jeffredodd merged commit 079a93a into main Feb 13, 2026
14 checks passed
@jeffredodd jeffredodd deleted the copilot/implement-semver-specification branch February 13, 2026 22:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants