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
6 changes: 3 additions & 3 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ jobs:
- name: Install dependencies
run: npm install

- name: Validate library
run: npm run validate

- name: Install example dependencies
run: npm --prefix example install

- name: Validate library
run: npm run validate

- name: Build example
run: npm run example:build
269 changes: 79 additions & 190 deletions .github/workflows/pr-metadata.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ jobs:
id: semantic_pr
continue-on-error: true
uses: amannn/action-semantic-pull-request@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
Expand Down Expand Up @@ -54,216 +56,125 @@ jobs:
const owner = context.repo.owner;
const repo = context.repo.repo;

const bumpPriority = {
none: 0,
patch: 1,
minor: 2,
major: 3,
};
// --- Version helpers ---

const bumpPriority = { none: 0, patch: 1, minor: 2, major: 3 };

const parseSemverTag = (tagName) => {
const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(tagName);
if (!match) {
return undefined;
}

return {
tagName,
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
};
if (!match) return undefined;
return { tagName, major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
};

const compareVersions = (left, right) => {
if (left.major !== right.major) {
return left.major - right.major;
}
const compareVersions = (a, b) =>
a.major - b.major || a.minor - b.minor || a.patch - b.patch;

if (left.minor !== right.minor) {
return left.minor - right.minor;
}

return left.patch - right.patch;
const bumpVersion = (v, bump) => {
if (bump === 'major') return { ...v, major: v.major + 1, minor: 0, patch: 0 };
if (bump === 'minor') return { ...v, minor: v.minor + 1, patch: 0 };
if (bump === 'patch') return { ...v, patch: v.patch + 1 };
return { ...v };
};

const bumpVersion = (version, bump) => {
if (bump === 'major') {
return {
...version,
major: version.major + 1,
minor: 0,
patch: 0,
};
}
const formatVersion = (v) => `v${v.major}.${v.minor}.${v.patch}`;

if (bump === 'minor') {
return {
...version,
minor: version.minor + 1,
patch: 0,
};
}
const pickHigherBump = (a, b) =>
bumpPriority[a] >= bumpPriority[b] ? a : b;

if (bump === 'patch') {
return {
...version,
patch: version.patch + 1,
};
}

return { ...version };
};

const formatVersion = (version) =>
`v${version.major}.${version.minor}.${version.patch}`;

const pickHigherBump = (left, right) =>
bumpPriority[left] >= bumpPriority[right] ? left : right;
// --- Conventional commit parsing ---

const parseConventionalMessage = (message) => {
const [header = ''] = message.split('\n');
const match = /^(?<type>[a-z]+)(?:\([^)]+\))?(?<breaking>!)?: .+$/.exec(
header.trim()
);
const match = /^(?<type>[a-z]+)(?:\([^)]+\))?(?<breaking>!)?: .+$/.exec(header.trim());
const breaking =
/(^|\n)BREAKING CHANGE:/m.test(message) ||
/(^|\n)BREAKING-CHANGE:/m.test(message) ||
Boolean(match?.groups?.breaking);
const type = match?.groups?.type;

let bump = 'none';
if (breaking) bump = 'major';
else if (type === 'feat') bump = 'minor';
else if (type === 'fix' || type === 'deps') bump = 'patch';

if (breaking) {
bump = 'major';
} else if (type === 'feat') {
bump = 'minor';
} else if (type === 'fix' || type === 'deps') {
bump = 'patch';
}

return {
header: header.trim(),
type,
bump,
valid: Boolean(match),
};
return { header: header.trim(), type, bump, valid: Boolean(match) };
};

// --- Bump resolution ---

const summarizeBumpSources = (infos) => {
const counts = {
major: 0,
minor: 0,
patch: 0,
none: 0,
};
const counts = { major: 0, minor: 0, patch: 0, none: 0 };
const types = new Map();

for (const info of infos) {
counts[info.bump] += 1;
if (info.type) {
types.set(info.type, (types.get(info.type) ?? 0) + 1);
}
if (info.type) types.set(info.type, (types.get(info.type) ?? 0) + 1);
}

const typeSummary = [...types.entries()]
.sort((left, right) => left[0].localeCompare(right[0]))
.map(([type, count]) => `${type}×${count}`)
.sort(([a], [b]) => a.localeCompare(b))
.map(([type, count]) => `${type}\u00d7${count}`)
.join(', ');

return {
counts,
typeSummary: typeSummary || 'none',
};
return { counts, typeSummary: typeSummary || 'none' };
};

const resolveBump = (infos) =>
infos.reduce(
(current, info) => pickHigherBump(current, info.bump),
'none'
);
infos.reduce((current, info) => pickHigherBump(current, info.bump), 'none');

// --- Collect version tags ---

const tags = await github.paginate(github.rest.repos.listTags, {
owner,
repo,
per_page: 100,
owner, repo, per_page: 100,
});
const versions = tags
.map((tag) => parseSemverTag(tag.name))
.filter(Boolean)
.sort((left, right) => compareVersions(right, left));
const latestVersion =
versions[0] ??
{
tagName: 'v0.0.0',
major: 0,
minor: 0,
patch: 0,
};

const baseCompare =
latestVersion.tagName === 'v0.0.0'
? { commits: [] }
: await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${latestVersion.tagName}...${pull.base.sha}`,
});

const prCommits = await github.paginate(
github.rest.pulls.listCommits,
{
owner,
repo,
pull_number: pull.number,
per_page: 100,
}
);
.sort((a, b) => compareVersions(b, a));
const latestVersion = versions[0] ?? { tagName: 'v0.0.0', major: 0, minor: 0, patch: 0 };

// --- Compare commits ---

const baseCompare = latestVersion.tagName === 'v0.0.0'
? { data: { commits: [] } }
: await github.rest.repos.compareCommitsWithBasehead({
owner, repo,
basehead: `${latestVersion.tagName}...${pull.base.sha}`,
});

const prCommits = await github.paginate(github.rest.pulls.listCommits, {
owner, repo, pull_number: pull.number, per_page: 100,
});

const combinedCommits = [
...(baseCompare.data?.commits ?? []),
...prCommits,
];
const uniqueCommits = [
...new Map(
combinedCommits.map((commit) => [commit.sha, commit])
).values(),
...new Map(combinedCommits.map((c) => [c.sha, c])).values(),
];

const prInfos = prCommits.map((commit) =>
parseConventionalMessage(commit.commit.message)
);
const projectedInfos = uniqueCommits.map((commit) =>
parseConventionalMessage(commit.commit.message)
);
// --- Compute bumps ---

const prInfos = prCommits.map((c) => parseConventionalMessage(c.commit.message));
const projectedInfos = uniqueCommits.map((c) => parseConventionalMessage(c.commit.message));
const titleInfo = parseConventionalMessage(pull.title);
const prBump = resolveBump(prInfos);
const projectedBump = resolveBump(projectedInfos);

const bootstrapFloor = parseSemverTag(
`v${process.env.BOOTSTRAP_FLOOR}`
);
const hasV2Tag = versions.some((version) => version.major >= 2);
const bootstrapFloor = parseSemverTag(`v${process.env.BOOTSTRAP_FLOOR}`);
const hasV2Tag = versions.some((v) => v.major >= 2);
const needsBootstrapFloor = bootstrapFloor && !hasV2Tag;

const prOnlyNextVersion = bumpVersion(latestVersion, prBump);
let projectedNextVersion = bumpVersion(
latestVersion,
projectedBump
);
let projectedNextVersion = bumpVersion(latestVersion, projectedBump);

if (
needsBootstrapFloor &&
compareVersions(projectedNextVersion, bootstrapFloor) < 0
) {
if (needsBootstrapFloor && compareVersions(projectedNextVersion, bootstrapFloor) < 0) {
projectedNextVersion = bootstrapFloor;
}

const titleState =
process.env.TITLE_VALID === 'true' ? 'valid' : 'invalid';
// --- Build comment ---

const titleState = process.env.TITLE_VALID === 'true' ? 'valid' : 'invalid';
const titleBump = titleInfo.bump;
const mismatch =
titleState === 'valid' && titleBump !== prBump;
const mismatch = titleState === 'valid' && titleBump !== prBump;
const prSummary = summarizeBumpSources(prInfos);
const projectedSummary = summarizeBumpSources(projectedInfos);

Expand All @@ -276,63 +187,41 @@ jobs:
`- PR-only next version: \`${formatVersion(prOnlyNextVersion)}\``,
`- Projected next release if merged now: \`${formatVersion(projectedNextVersion)}\``,
`- Projected bump across unreleased work on \`${pull.base.ref}\` + this PR: \`${projectedBump}\` (${projectedSummary.typeSummary})`,
`- PR title check: ${titleState === 'valid' ? ' valid' : ' invalid'} (\`${pull.title}\`)`,
`- PR title check: ${titleState === 'valid' ? '\u2705 valid' : '\u274c invalid'} (\`${pull.title}\`)`,
];

if (mismatch) {
lines.push(
`- Title/commit mismatch: ⚠️ title implies \`${titleBump}\`, PR commits imply \`${prBump}\``
);
lines.push(`- Title/commit mismatch: \u26a0\ufe0f title implies \`${titleBump}\`, PR commits imply \`${prBump}\``);
} else if (titleState === 'valid') {
lines.push(
`- Title/commit alignment: ✅ title implies \`${titleBump}\` and matches the PR commits`
);
lines.push(`- Title/commit alignment: \u2705 title implies \`${titleBump}\` and matches the PR commits`);
} else {
lines.push(
'- Title/commit alignment: ⚠️ fix the PR title to match Conventional Commits before merge'
);
lines.push('- Title/commit alignment: \u26a0\ufe0f fix the PR title to match Conventional Commits before merge');
}

if (needsBootstrapFloor) {
lines.push(
`- Bootstrap note: the release line is being rebased onto \`${formatVersion(bootstrapFloor)}\`, so projected releases are floored at that version until \`v2.0.0\` exists`
);
lines.push(`- Bootstrap note: the release line is being rebased onto \`${formatVersion(bootstrapFloor)}\`, so projected releases are floored at that version until \`v2.0.0\` exists`);
}

lines.push(
'',
'_Advisory only. Release Please remains the source of truth for the final tag and changelog._'
);
lines.push('', '_Advisory only. Release Please remains the source of truth for the final tag and changelog._');

const body = lines.join('\n');
const comments = await github.paginate(
github.rest.issues.listComments,
{
owner,
repo,
issue_number: pull.number,
per_page: 100,
}
);
const existingComment = comments.find(
(comment) =>
comment.user?.type === 'Bot' &&
comment.body?.includes(marker)

// --- Upsert comment ---

const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pull.number, per_page: 100,
});
const existing = comments.find(
(c) => c.user?.type === 'Bot' && c.body?.includes(marker)
);

if (existingComment) {
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existingComment.id,
body,
owner, repo, comment_id: existing.id, body,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number: pull.number,
body,
owner, repo, issue_number: pull.number, body,
});
}

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ dist
coverage
*.tgz
storybook-static
.claude
Loading