Skip to content

ci: add changeset automation #1653

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Apr 9, 2025
85 changes: 85 additions & 0 deletions .github/workflows/auto-changeset.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
name: Changeset Automation

on:
pull_request_target:
types:
- opened
- edited
- synchronize
- reopened
- ready_for_review

jobs:
auto-changeset:
if: |
startsWith(github.repository, 'asyncapi/') &&
(!contains(github.event.pull_request.labels, 'skip-changeset')) &&
( startsWith(github.event.pull_request.title, 'fix:') ||
startsWith(github.event.pull_request.title, 'feat:') ||
startsWith(github.event.pull_request.title, 'fix!:') ||
startsWith(github.event.pull_request.title, 'feat!:')
)
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GH_TOKEN }}

- name: Checkout PR
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
run: gh pr checkout ${{ github.event.pull_request.number }}

- name: Install specific package in temp dir
run: |
mkdir temp-install
cd temp-install
npm init -y
npm install read-package-up@11.0.0
cp -r node_modules ../node_modules
cd ..
rm -rf temp-install

- name: Get changeset contents
id: get_changeset_contents
uses: actions/github-script@v7
with:
script: |
const { getChangesetContents } = require('./.github/workflows/changeset-utils/index.js')
const pullRequest = context.payload.pull_request;
const changesetContents = await getChangesetContents(pullRequest, github)
return changesetContents;

- name: Create changeset file
run: "echo -e ${{ steps.get_changeset_contents.outputs.result }} > .changeset/${{ github.event.pull_request.number }}.md"

- name: Commit changeset file
run: |
git config --global user.name asyncapi-bot
git config --global user.email info@asyncapi.io
git add .changeset/${{ github.event.pull_request.number }}.md
# Check if there are any changes to commit
if git diff --quiet HEAD; then
echo "No changes to commit"
else
git commit -m "chore: add changeset for PR #${{ github.event.pull_request.number }}"
fi

- name: Push changeset file
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
git remote set-url origin https://github.com/${{ github.event.pull_request.head.repo.full_name }}
git push origin HEAD:${{ github.event.pull_request.head.ref }}

- name: Comment workflow
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
uses: actions/github-script@v7
with:
script: |
const { commentWorkflow } = require('./.github/workflows/changeset-utils/index.js')
const pullRequest = context.payload.pull_request;
const changesetContents = ${{ steps.get_changeset_contents.outputs.result }}
await commentWorkflow(pullRequest, github, changesetContents)
137 changes: 137 additions & 0 deletions .github/workflows/changeset-utils/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
const path = require('path');
const { readPackageUp } = require('read-package-up');

// @todo
// Check if maintainer can modify the head

const getFormattedCommits = async (pullRequest, github) => {
const commitOpts = github.rest.pulls.listCommits.endpoint.merge({
owner: pullRequest.base.repo.owner.login,
repo: pullRequest.base.repo.name,
pull_number: pullRequest.number,
});

const commits = await github.paginate(commitOpts);

// Filter merge commits and commits by asyncapi-bot
const filteredCommits = commits.filter((commit) => {
return !commit.commit.message.startsWith('Merge pull request') && !commit.commit.message.startsWith('Merge branch') && !commit.commit.author.name.startsWith('asyncapi-bot') && !commit.commit.author.name.startsWith('dependabot[bot]');
});

return filteredCommits.map((commit) => {
return {
commit_sha: commit.sha.slice(0, 7), // first 7 characters of the commit sha is enough to identify the commit
commit_message: commit.commit.message,
};
});
}

const getReleasedPackages = async (pullRequest, github) => {
const files = await github.paginate(github.rest.pulls.listFiles.endpoint.merge({
owner: pullRequest.base.repo.owner.login,
repo: pullRequest.base.repo.name,
pull_number: pullRequest.number,
}));

const releasedPackages = [];
const ignoredFiles = ['README.md', 'CHANGELOG.md', './changeset/README.md', 'package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'];
for (const file of files) {
if (!ignoredFiles.includes(file.filename)) {
const cwd = path.resolve(path.dirname(file.filename));
const pack = await readPackageUp({ cwd });
if (pack && pack?.packageJson?.name && !releasedPackages.includes(pack.packageJson.name)) {
releasedPackages.push(pack.packageJson.name);
}
}
}

console.debug('Filenames', files.map((file) => file.filename));
return releasedPackages;
}

const getReleaseNotes = async (pullRequest, github) => {
const commits = await getFormattedCommits(pullRequest, github);
/**
* Release notes are generated from the commits.
* Format:
* - title
* - commit_sha: commit_message (Array of commits)
*/
const releaseNotes = pullRequest.title + '\n\n' + commits.map((commit) => {
return `- ${commit.commit_sha}: ${commit.commit_message}`;
}).join('\n');

return releaseNotes;
}

const getChangesetContents = async (pullRequest, github) => {
const title = pullRequest.title;
const releaseType = title.split(':')[0];
let releaseVersion = 'patch';
switch (releaseType) {
case 'fix':
releaseVersion = 'patch';
case 'feat':
releaseVersion = 'minor';
case 'fix!':
releaseVersion = 'major';
case 'feat!':
releaseVersion = 'major';
default:
releaseVersion = 'patch';
}

const releaseNotes = await getReleaseNotes(pullRequest, github);
const releasedPackages = await getReleasedPackages(pullRequest, github);

if (releasedPackages.length === 0) {
console.debug('No packages released');
return '';
}
console.debug('Released packages', releasedPackages);
console.debug('Release notes', releaseNotes);

const changesetContents = `---\n` + releasedPackages.map((pkg) => {
return `'${pkg}': ${releaseVersion}`;
}).join('\n') + `\n---\n\n${releaseNotes}\n\n`

return changesetContents;
};

/**
* This function checks if a comment has already been created by the workflow.
* If not, it creates a comment with the changeset.
* If it is already created, it updates the comment with the new changeset.
*/
const commentWorkflow = async (pullRequest, github, changesetContents) => {
const body = `#### Changeset has been generated for this PR as part of auto-changeset workflow.\n\n<details><summary>Please review the changeset before merging the PR.</summary>\n\n\`\`\`\n${changesetContents}\`\`\`\n\n</details>\n\n[If you are a maintainer or the author of the PR, you can change the changeset by clicking here](https://github.com/${pullRequest.head.repo.full_name}/edit/${pullRequest.head.ref}/.changeset/${pullRequest.number}.md)\n\n> [!TIP]\n> If you don't want auto-changeset to run on this PR, you can add the label \`skip-changeset\` to the PR.`

const comments = await github.rest.issues.listComments({
owner: pullRequest.base.repo.owner.login,
repo: pullRequest.base.repo.name,
issue_number: pullRequest.number,
});

const comment = comments.data.find((comment) => comment.body.includes('Changeset has been generated for this PR as part of auto-changeset workflow.'));
if (comment) {
await github.rest.issues.updateComment({
owner: pullRequest.base.repo.owner.login,
repo: pullRequest.base.repo.name,
comment_id: comment.id,
body: body,
});
} else {
await github.rest.issues.createComment({
owner: pullRequest.base.repo.owner.login,
repo: pullRequest.base.repo.name,
issue_number: pullRequest.number,
body: body,
user: 'asyncapi-bot',
});
}
}

module.exports = {
getChangesetContents,
commentWorkflow,
};
Loading