Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
node_modules/
lib/
dist/
# dist/ is intentionally tracked — GitHub Actions requires the bundled output
# to be committed so callers can reference the action without running npm install.
# The build-publish.yml workflow rebuilds and re-commits dist/ on every push to main.

logs
*.log
*.pid
89 changes: 58 additions & 31 deletions commit-message-validator.js
Original file line number Diff line number Diff line change
@@ -1,71 +1,98 @@
const core = require('@actions/core');
const github = require('@actions/github');

function buildDefaultPattern() {
const mergeBranchPattern = 'Merge branch [\'"][^\'"]+[\'"] into [^\\s]+';
const revertPattern = 'Revert ".*"';
const types = [
'feat', 'fix', 'chore', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'revert',
].join('|');
// Scope allows lowercase/uppercase letters, digits, underscores, slashes, and hyphens
const conventionalPattern = `(?:(${types})(\\([a-zA-Z0-9_/\\-]+\\))?(!)?: .+)`;
return `^(${mergeBranchPattern}|${revertPattern}|${conventionalPattern})$`;
}

async function run() {
try {
// Get inputs from workflow
const token = core.getInput('github-token', { required: true });

const mergeBranchPattern = 'Merge branch [\'"][^\'"]+[\'"] into [^\\s]+';
const revertPattern = 'Revert ".*"';
const createPrPattern = 'Create PR for #\\d+';
const types = [
'feat', 'fix', 'chore', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'revert'
].join('|');
const conventionalPattern = `(?:(${types})(\\([a-z0-9\\-]+\\))?(!)?: .*)`;
const defaultPattern = buildDefaultPattern();
const rawPattern = core.getInput('pattern') || defaultPattern;

const defaultPattern = `^(${mergeBranchPattern}|${revertPattern}${createPrPattern ? '|' + createPrPattern : ''}|${conventionalPattern})$`;
const pattern = core.getInput('pattern') || defaultPattern;
const regexPattern = new RegExp(pattern);
let regexPattern;
try {
regexPattern = new RegExp(rawPattern);
} catch (e) {
core.setFailed(`Invalid regex pattern provided: ${e.message}`);
return;
}

// Create octokit client
const octokit = github.getOctokit(token);
const context = github.context;

// Get current PR
const pullRequest = context.payload.pull_request;
if (!pullRequest) {
core.info('No pull request found. Skipping commit message validation.');
return;
}

// Get commits in PR
const { data: commits } = await octokit.rest.pulls.listCommits({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pullRequest.number,
});

if (commits.length === 0) {
core.info('No commits found in this pull request. Skipping validation.');
return;
}

let hasError = false;
let errorMessages = [];
const errorMessages = [];

// Validate each commit message
commits.forEach((commit) => {
const commitMessage = commit.commit.message.split('\n')[0].trim();
const message = commit.commit.message;
if (!message) {
core.warning(`Commit ${commit.sha.substring(0, 7)} has an empty message, skipping.`);
return;
}
const commitMessage = message.split('\n')[0].trim();
const sha = commit.sha.substring(0, 7);

if (!regexPattern.test(commitMessage)) {
const errorMsg = `❌ Commit ${sha} has an invalid message format: "${commitMessage}"`;
core.error(errorMsg);
errorMessages.push(errorMsg);
// Escape backticks to avoid breaking markdown code spans in PR comments
const safeMessage = commitMessage.replace(/`/g, '\\`');
errorMessages.push(`- ❌ \`${sha}\`: "${safeMessage}"`);
hasError = true;
} else {
core.info(`✅ Commit ${sha} has a valid message: "${commitMessage}"`);
}
});

// Fail the workflow if errors are found
if (hasError) {
const errorSummary = `One or more commits have invalid message format.\n${errorMessages.join('\n')}\n\nPlease follow the Conventional Commits format: <type>[optional scope]: <description>\nExample: feat(auth): add login functionality\n\nFor more information, visit: https://www.conventionalcommits.org/`;
// comment on the PR with the error summary
await octokit.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
body: errorSummary,
});
core.info('Commented on PR with error summary.');
// Set the action as failed
const errorSummary = [
'One or more commits have invalid message format.',
'',
errorMessages.join('\n'),
'',
'Please follow the Conventional Commits format: `<type>[optional scope]: <description>`',
'Example: `feat(auth): add login functionality`',
'',
'For more information, visit: https://www.conventionalcommits.org/',
].join('\n');

try {
await octokit.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
body: errorSummary,
});
core.info('Commented on PR with error summary.');
} catch (commentError) {
core.warning(`Failed to post PR comment: ${commentError.message}`);
}

core.setFailed(errorSummary);
} else {
core.info('✅ All commit messages have valid format.');
Expand Down
Loading
Loading