From 2e3c2f5cc34bba4132e548516e737d79761e0c0f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Mar 2026 16:32:07 +0000
Subject: [PATCH 1/3] Initial plan
From ea7547f67715e2ea3c623221a7d37df64ad81d05 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Mar 2026 16:45:56 +0000
Subject: [PATCH 2/3] feat: substitute LLM bot authors with human assignees in
release notes
- Add src/release-author-substitutor.js with utility functions for
detecting LLM/AI bot users and replacing their author mentions with
the PR's human assignee in release note bodies
- Add test/release-author-substitutor.test.js with 34 unit tests
(Node.js built-in test runner, all passing)
- Add package.json with npm test script
- Update shared-auto-release.yml with new llm-users input and a
post-processing step that patches the release body when LLM-authored
PRs are found
Co-authored-by: nitrocode <7775707+nitrocode@users.noreply.github.com>
---
.github/workflows/shared-auto-release.yml | 129 +++++++++
package.json | 12 +
src/release-author-substitutor.js | 97 +++++++
test/release-author-substitutor.test.js | 313 ++++++++++++++++++++++
4 files changed, 551 insertions(+)
create mode 100644 package.json
create mode 100644 src/release-author-substitutor.js
create mode 100644 test/release-author-substitutor.test.js
diff --git a/.github/workflows/shared-auto-release.yml b/.github/workflows/shared-auto-release.yml
index 9ec2a288..ee214598 100644
--- a/.github/workflows/shared-auto-release.yml
+++ b/.github/workflows/shared-auto-release.yml
@@ -12,6 +12,16 @@ on:
required: false
default: false
type: string
+ llm-users:
+ description: >
+ JSON array of LLM/AI bot GitHub usernames whose PR authorship should be replaced
+ with the PR's human assignee in the release notes.
+ Example: '["github-copilot[bot]","devin-ai-integration[bot]"]'
+ Glob patterns with '*' are supported for flexible matching.
+ Defaults to a built-in list that includes GitHub Copilot and other common AI assistants.
+ required: false
+ default: ''
+ type: string
runs-on:
description: "Overrides job runs-on setting (json-encoded list)"
type: string
@@ -128,6 +138,125 @@ jobs:
summary-enabled: ${{ inputs.summary-enabled }}
config-name: ${{ steps.context.outputs.config }}
+ # When a PR was authored by an LLM/AI assistant, replace the bot username with the
+ # PR's human assignee in the release notes so humans get proper credit.
+ # The utility functions below mirror src/release-author-substitutor.js (unit-tested).
+ - name: Substitute LLM authors in release notes
+ if: ${{ steps.drafter.outputs.id != '' }}
+ uses: actions/github-script@v7
+ env:
+ LLM_USERS: ${{ inputs.llm-users }}
+ with:
+ github-token: ${{ steps.github-app.outputs.token }}
+ retries: 3
+ script: |
+ // Default list of LLM/AI assistant usernames.
+ // Mirrors src/release-author-substitutor.js DEFAULT_LLM_USERS.
+ const DEFAULT_LLM_USERS = [
+ 'github-copilot[bot]',
+ 'copilot',
+ 'devin-ai-integration[bot]',
+ 'amazon-q[bot]',
+ 'codeium[bot]',
+ ];
+
+ const llmUsersInput = process.env.LLM_USERS;
+ const llmUsers = (llmUsersInput && llmUsersInput.trim())
+ ? JSON.parse(llmUsersInput)
+ : DEFAULT_LLM_USERS;
+
+ function isLLMUser(username) {
+ const norm = username.toLowerCase();
+ return llmUsers.some(llmUser => {
+ const nu = llmUser.toLowerCase();
+ if (nu.includes('*')) {
+ const regexStr = nu.split('*').map(s => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*');
+ return new RegExp(`^${regexStr}$`).test(norm);
+ }
+ return norm === nu;
+ });
+ }
+
+ function extractPRReferences(body) {
+ const prPattern = /@([a-zA-Z0-9_.\-[\]]+)\s+\(#(\d+)\)/g;
+ const matches = [];
+ let match;
+ while ((match = prPattern.exec(body)) !== null) {
+ // fullMatch is omitted here (unused); see src/release-author-substitutor.js for full shape
+ matches.push({ author: match[1], prNumber: parseInt(match[2], 10) });
+ }
+ return matches;
+ }
+
+ function substituteLLMAuthors(body, prSubstitutions) {
+ let updatedBody = body;
+ for (const { prNumber, oldAuthor, newAuthor } of prSubstitutions) {
+ const escapedAuthor = oldAuthor.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
+ const pattern = new RegExp(`@${escapedAuthor}(\\s+\\(#${prNumber}\\))`, 'g');
+ updatedBody = updatedBody.replace(pattern, `@${newAuthor}$1`);
+ }
+ return updatedBody;
+ }
+
+ const releaseId = parseInt('${{ steps.drafter.outputs.id }}', 10);
+
+ const { data: release } = await github.rest.repos.getRelease({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ release_id: releaseId,
+ });
+
+ const body = release.body || '';
+ const prRefs = extractPRReferences(body);
+ const llmPRRefs = prRefs.filter(ref => isLLMUser(ref.author));
+
+ if (llmPRRefs.length === 0) {
+ core.info('No LLM-authored PRs found in release notes');
+ return;
+ }
+
+ const prSubstitutions = [];
+
+ for (const prRef of llmPRRefs) {
+ try {
+ const { data: pr } = await github.rest.pulls.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: prRef.prNumber,
+ });
+
+ const humanAssignee = (pr.assignees || []).find(a => !isLLMUser(a.login));
+ if (humanAssignee) {
+ prSubstitutions.push({
+ prNumber: prRef.prNumber,
+ oldAuthor: prRef.author,
+ newAuthor: humanAssignee.login,
+ });
+ core.info(`PR #${prRef.prNumber}: substituting @${prRef.author} with @${humanAssignee.login}`);
+ } else {
+ core.info(`PR #${prRef.prNumber}: no human assignee found for @${prRef.author}, keeping original`);
+ }
+ } catch (error) {
+ core.warning(`Failed to get assignees for PR #${prRef.prNumber}: ${error.message}`);
+ }
+ }
+
+ if (prSubstitutions.length === 0) {
+ core.info('No substitutions needed');
+ return;
+ }
+
+ const updatedBody = substituteLLMAuthors(body, prSubstitutions);
+
+ await github.rest.repos.updateRelease({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ release_id: releaseId,
+ body: updatedBody,
+ });
+
+ core.info(`Release notes updated: ${prSubstitutions.length} LLM author(s) replaced with human assignee(s)`);
+
# - uses: actions/github-script@v7
# if: ${{ steps.context.outputs.config == 'auto-feature-release.yml' && steps.drafter.outputs.id != '' }}
# with:
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..962c2dc0
--- /dev/null
+++ b/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@cloudposse/github-workflows",
+ "version": "1.0.0",
+ "description": "Shared GitHub workflows and release automation for Cloud Posse",
+ "private": true,
+ "scripts": {
+ "test": "node --test 'test/**/*.test.js'"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+}
diff --git a/src/release-author-substitutor.js b/src/release-author-substitutor.js
new file mode 100644
index 00000000..f1bb3aef
--- /dev/null
+++ b/src/release-author-substitutor.js
@@ -0,0 +1,97 @@
+'use strict';
+
+/**
+ * Default list of LLM/AI assistant usernames to detect in release notes.
+ * These will be replaced with the PR's human assignee when generating release notes.
+ * Supports exact match (case-insensitive) and glob patterns using '*'.
+ */
+const DEFAULT_LLM_USERS = [
+ 'github-copilot[bot]',
+ 'copilot',
+ 'devin-ai-integration[bot]',
+ 'amazon-q[bot]',
+ 'codeium[bot]',
+];
+
+/**
+ * Converts a glob pattern (with * wildcards) to a RegExp that matches the full string.
+ * @param {string} pattern - Glob pattern (case-insensitive, already lowercased)
+ * @returns {RegExp}
+ */
+function globToRegex(pattern) {
+ const regexStr = pattern
+ .split('*')
+ .map(s => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
+ .join('.*');
+ return new RegExp(`^${regexStr}$`);
+}
+
+/**
+ * Checks whether a GitHub username matches an LLM/AI user pattern.
+ * Matching is case-insensitive. Supports exact matches and glob patterns using '*'.
+ *
+ * @param {string} username - GitHub username to check
+ * @param {string[]} [llmUsers] - List of LLM usernames or glob patterns.
+ * Defaults to DEFAULT_LLM_USERS when not provided.
+ * @returns {boolean} True if the username matches any entry in the LLM users list
+ */
+function isLLMUser(username, llmUsers) {
+ const users = llmUsers || DEFAULT_LLM_USERS;
+ const norm = username.toLowerCase();
+ return users.some(llmUser => {
+ const nu = llmUser.toLowerCase();
+ if (nu.includes('*')) {
+ return globToRegex(nu).test(norm);
+ }
+ return norm === nu;
+ });
+}
+
+/**
+ * Extracts PR author and number references from a release notes body.
+ * Matches patterns produced by the release-drafter change-template, e.g.:
+ * "@username (#123)"
+ *
+ * @param {string} body - Release notes body text
+ * @returns {Array<{author: string, prNumber: number, fullMatch: string}>}
+ */
+function extractPRReferences(body) {
+ const prPattern = /@([a-zA-Z0-9_.\-[\]]+)\s+\(#(\d+)\)/g;
+ const matches = [];
+ let match;
+ while ((match = prPattern.exec(body)) !== null) {
+ matches.push({
+ fullMatch: match[0],
+ author: match[1],
+ prNumber: parseInt(match[2], 10),
+ });
+ }
+ return matches;
+}
+
+/**
+ * Substitutes LLM bot authors with human assignees in release notes.
+ * Each substitution is targeted per-PR number so that multiple PRs from the
+ * same bot can be attributed to different human assignees.
+ *
+ * @param {string} body - Release notes body text
+ * @param {Array<{prNumber: number, oldAuthor: string, newAuthor: string}>} prSubstitutions
+ * @returns {string} Updated release notes body
+ */
+function substituteLLMAuthors(body, prSubstitutions) {
+ let updatedBody = body;
+ for (const { prNumber, oldAuthor, newAuthor } of prSubstitutions) {
+ const escapedAuthor = oldAuthor.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
+ const pattern = new RegExp(`@${escapedAuthor}(\\s+\\(#${prNumber}\\))`, 'g');
+ updatedBody = updatedBody.replace(pattern, `@${newAuthor}$1`);
+ }
+ return updatedBody;
+}
+
+module.exports = {
+ DEFAULT_LLM_USERS,
+ globToRegex,
+ isLLMUser,
+ extractPRReferences,
+ substituteLLMAuthors,
+};
diff --git a/test/release-author-substitutor.test.js b/test/release-author-substitutor.test.js
new file mode 100644
index 00000000..eb4353db
--- /dev/null
+++ b/test/release-author-substitutor.test.js
@@ -0,0 +1,313 @@
+'use strict';
+
+const { describe, it, before } = require('node:test');
+const assert = require('node:assert/strict');
+const {
+ DEFAULT_LLM_USERS,
+ globToRegex,
+ isLLMUser,
+ extractPRReferences,
+ substituteLLMAuthors,
+} = require('../src/release-author-substitutor');
+
+describe('DEFAULT_LLM_USERS', () => {
+ it('is a non-empty array', () => {
+ assert.ok(Array.isArray(DEFAULT_LLM_USERS));
+ assert.ok(DEFAULT_LLM_USERS.length > 0);
+ });
+
+ it('includes github-copilot[bot]', () => {
+ assert.ok(DEFAULT_LLM_USERS.includes('github-copilot[bot]'));
+ });
+
+ it('includes copilot', () => {
+ assert.ok(DEFAULT_LLM_USERS.includes('copilot'));
+ });
+
+ it('includes devin-ai-integration[bot]', () => {
+ assert.ok(DEFAULT_LLM_USERS.includes('devin-ai-integration[bot]'));
+ });
+});
+
+describe('globToRegex', () => {
+ it('matches exact string with no wildcards', () => {
+ const re = globToRegex('copilot');
+ assert.ok(re.test('copilot'));
+ assert.ok(!re.test('xcopilotx'));
+ assert.ok(!re.test('copilot-extra'));
+ });
+
+ it('matches strings with leading wildcard', () => {
+ const re = globToRegex('*bot]');
+ assert.ok(re.test('github-copilot[bot]'));
+ assert.ok(re.test('[bot]'));
+ assert.ok(!re.test('github-actions'));
+ });
+
+ it('matches strings with trailing wildcard', () => {
+ const re = globToRegex('github-copilot*');
+ assert.ok(re.test('github-copilot[bot]'));
+ assert.ok(re.test('github-copilot'));
+ assert.ok(!re.test('github-actions[bot]'));
+ });
+
+ it('matches strings with surrounding wildcards', () => {
+ const re = globToRegex('*copilot*');
+ assert.ok(re.test('github-copilot[bot]'));
+ assert.ok(re.test('copilot'));
+ assert.ok(re.test('my-copilot-assistant'));
+ assert.ok(!re.test('githubbot'));
+ });
+
+ it('escapes special regex characters in the pattern', () => {
+ const re = globToRegex('github-copilot[bot]');
+ assert.ok(re.test('github-copilot[bot]'));
+ // Should NOT match with arbitrary characters in place of [bot]
+ assert.ok(!re.test('github-copilotXbotY'));
+ });
+
+ it('handles multiple wildcards', () => {
+ const re = globToRegex('github*copilot*');
+ assert.ok(re.test('github-copilot[bot]'));
+ assert.ok(re.test('github_copilot'));
+ assert.ok(!re.test('copilot-github'));
+ });
+});
+
+describe('isLLMUser', () => {
+ describe('with default LLM users list', () => {
+ it('returns true for github-copilot[bot]', () => {
+ assert.ok(isLLMUser('github-copilot[bot]'));
+ });
+
+ it('returns true for copilot', () => {
+ assert.ok(isLLMUser('copilot'));
+ });
+
+ it('returns true for devin-ai-integration[bot]', () => {
+ assert.ok(isLLMUser('devin-ai-integration[bot]'));
+ });
+
+ it('returns false for a regular human user', () => {
+ assert.ok(!isLLMUser('octocat'));
+ assert.ok(!isLLMUser('alice'));
+ assert.ok(!isLLMUser('bob'));
+ });
+
+ it('returns false for dependabot (not in default LLM list)', () => {
+ assert.ok(!isLLMUser('dependabot[bot]'));
+ });
+
+ it('is case-insensitive', () => {
+ assert.ok(isLLMUser('GITHUB-COPILOT[BOT]'));
+ assert.ok(isLLMUser('Copilot'));
+ assert.ok(isLLMUser('GitHub-Copilot[Bot]'));
+ });
+ });
+
+ describe('with custom LLM users list', () => {
+ it('matches usernames in the custom list', () => {
+ const customList = ['my-ai-bot', 'custom-llm'];
+ assert.ok(isLLMUser('my-ai-bot', customList));
+ assert.ok(isLLMUser('custom-llm', customList));
+ });
+
+ it('does not match usernames from the default list when custom list is provided', () => {
+ const customList = ['my-ai-bot'];
+ assert.ok(!isLLMUser('github-copilot[bot]', customList));
+ });
+
+ it('supports glob patterns in custom list', () => {
+ const withGlob = ['*copilot*'];
+ assert.ok(isLLMUser('github-copilot[bot]', withGlob));
+ assert.ok(isLLMUser('copilot-assistant', withGlob));
+ assert.ok(isLLMUser('my-copilot', withGlob));
+ assert.ok(!isLLMUser('octocat', withGlob));
+ });
+
+ it('supports prefix glob patterns', () => {
+ const withGlob = ['devin-*'];
+ assert.ok(isLLMUser('devin-ai-integration[bot]', withGlob));
+ assert.ok(!isLLMUser('github-copilot[bot]', withGlob));
+ });
+
+ it('is case-insensitive with glob patterns', () => {
+ const withGlob = ['*COPILOT*'];
+ assert.ok(isLLMUser('github-copilot[bot]', withGlob));
+ });
+ });
+});
+
+describe('extractPRReferences', () => {
+ it('extracts a single PR reference', () => {
+ const body = 'Some feature @github-copilot[bot] (#123)';
+ const refs = extractPRReferences(body);
+ assert.strictEqual(refs.length, 1);
+ assert.strictEqual(refs[0].author, 'github-copilot[bot]');
+ assert.strictEqual(refs[0].prNumber, 123);
+ assert.strictEqual(refs[0].fullMatch, '@github-copilot[bot] (#123)');
+ });
+
+ it('extracts multiple PR references', () => {
+ const body = [
+ 'Feature A @alice (#100)',
+ 'Feature B @github-copilot[bot] (#101)',
+ 'Feature C @bob (#102)',
+ ].join('\n');
+ const refs = extractPRReferences(body);
+ assert.strictEqual(refs.length, 3);
+ assert.strictEqual(refs[0].author, 'alice');
+ assert.strictEqual(refs[0].prNumber, 100);
+ assert.strictEqual(refs[1].author, 'github-copilot[bot]');
+ assert.strictEqual(refs[1].prNumber, 101);
+ assert.strictEqual(refs[2].author, 'bob');
+ assert.strictEqual(refs[2].prNumber, 102);
+ });
+
+ it('returns an empty array when there are no PR references', () => {
+ const body = 'No PR references here';
+ const refs = extractPRReferences(body);
+ assert.strictEqual(refs.length, 0);
+ });
+
+ it('handles the HTML change-template format from release-drafter', () => {
+ const body = `
+ Add new feature @github-copilot[bot] (#42)
+ Some body text
+ `;
+ const refs = extractPRReferences(body);
+ assert.strictEqual(refs.length, 1);
+ assert.strictEqual(refs[0].author, 'github-copilot[bot]');
+ assert.strictEqual(refs[0].prNumber, 42);
+ });
+
+ it('handles a full realistic release notes body', () => {
+ const body = `## π Enhancements
+
+
+ Add feature X @github-copilot[bot] (#10)
+ Feature description
+
+
+
+ Refactor Y @alice (#11)
+ Refactor description
+
+
+## π Bug Fixes
+
+
+ Fix bug Z @devin-ai-integration[bot] (#12)
+ Bug fix description
+ `;
+ const refs = extractPRReferences(body);
+ assert.strictEqual(refs.length, 3);
+ assert.strictEqual(refs[0].author, 'github-copilot[bot]');
+ assert.strictEqual(refs[1].author, 'alice');
+ assert.strictEqual(refs[2].author, 'devin-ai-integration[bot]');
+ });
+
+ it('handles usernames with hyphens and dots', () => {
+ const body = '@some-user.name (#99)';
+ const refs = extractPRReferences(body);
+ assert.strictEqual(refs.length, 1);
+ assert.strictEqual(refs[0].author, 'some-user.name');
+ assert.strictEqual(refs[0].prNumber, 99);
+ });
+});
+
+describe('substituteLLMAuthors', () => {
+ it('replaces a single LLM author with a human assignee', () => {
+ const body = 'Add feature @github-copilot[bot] (#123)';
+ const subs = [{ prNumber: 123, oldAuthor: 'github-copilot[bot]', newAuthor: 'alice' }];
+ const result = substituteLLMAuthors(body, subs);
+ assert.strictEqual(result, 'Add feature @alice (#123)');
+ });
+
+ it('replaces multiple LLM authors with different human assignees', () => {
+ const body = [
+ 'Feature A @github-copilot[bot] (#100)',
+ 'Feature B @github-copilot[bot] (#101)',
+ ].join('\n');
+ const subs = [
+ { prNumber: 100, oldAuthor: 'github-copilot[bot]', newAuthor: 'alice' },
+ { prNumber: 101, oldAuthor: 'github-copilot[bot]', newAuthor: 'bob' },
+ ];
+ const result = substituteLLMAuthors(body, subs);
+ assert.ok(result.includes('@alice (#100)'));
+ assert.ok(result.includes('@bob (#101)'));
+ assert.ok(!result.includes('@github-copilot[bot]'));
+ });
+
+ it('does not modify entries for non-LLM authors', () => {
+ const body = [
+ 'Feature A @alice (#100)',
+ 'Feature B @github-copilot[bot] (#101)',
+ ].join('\n');
+ const subs = [{ prNumber: 101, oldAuthor: 'github-copilot[bot]', newAuthor: 'bob' }];
+ const result = substituteLLMAuthors(body, subs);
+ assert.ok(result.includes('@alice (#100)'));
+ assert.ok(result.includes('@bob (#101)'));
+ assert.ok(!result.includes('@github-copilot[bot]'));
+ });
+
+ it('returns the original body when no substitutions are provided', () => {
+ const body = 'Feature @alice (#100)';
+ const result = substituteLLMAuthors(body, []);
+ assert.strictEqual(result, body);
+ });
+
+ it('handles the HTML change-template format from release-drafter', () => {
+ const body = `
+ Add feature @github-copilot[bot] (#42)
+ Body text
+ `;
+ const subs = [{ prNumber: 42, oldAuthor: 'github-copilot[bot]', newAuthor: 'reviewer' }];
+ const result = substituteLLMAuthors(body, subs);
+ assert.ok(result.includes('@reviewer (#42)'));
+ assert.ok(!result.includes('@github-copilot[bot]'));
+ });
+
+ it('only replaces the matching PR number, not other occurrences', () => {
+ // Same bot authored two PRs; only PR #100 should be substituted
+ const body = [
+ 'Feature A @github-copilot[bot] (#100)',
+ 'Feature B @github-copilot[bot] (#101)',
+ ].join('\n');
+ const subs = [{ prNumber: 100, oldAuthor: 'github-copilot[bot]', newAuthor: 'alice' }];
+ const result = substituteLLMAuthors(body, subs);
+ assert.ok(result.includes('@alice (#100)'));
+ assert.ok(result.includes('@github-copilot[bot] (#101)'));
+ });
+
+ it('handles a full realistic release notes body', () => {
+ const body = `## π Enhancements
+
+
+ Add feature X @github-copilot[bot] (#10)
+ Feature description
+
+
+
+ Refactor Y @alice (#11)
+ Refactor description
+
+
+## π Bug Fixes
+
+
+ Fix bug Z @devin-ai-integration[bot] (#12)
+ Bug fix description
+ `;
+ const subs = [
+ { prNumber: 10, oldAuthor: 'github-copilot[bot]', newAuthor: 'carol' },
+ { prNumber: 12, oldAuthor: 'devin-ai-integration[bot]', newAuthor: 'dave' },
+ ];
+ const result = substituteLLMAuthors(body, subs);
+ assert.ok(result.includes('@carol (#10)'));
+ assert.ok(result.includes('@alice (#11)'));
+ assert.ok(result.includes('@dave (#12)'));
+ assert.ok(!result.includes('@github-copilot[bot]'));
+ assert.ok(!result.includes('@devin-ai-integration[bot]'));
+ });
+});
From 5472c46ce81e75b70c8d6d8b9c29175cfa97a9bc Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Mar 2026 20:03:20 +0000
Subject: [PATCH 3/3] refactor: move inline github-scripts to co-located
composite actions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Extract the two large inline github-script blocks from reusable workflows
into composite actions co-located in .github/actions/, following the Atmos
repository pattern.
- .github/actions/substitute-llm-authors/action.yml: composite action
extracted from shared-auto-release.yml; uses process.env instead of
template interpolation for the release-id (security improvement)
- .github/actions/substitute-llm-authors/release-author-substitutor.js:
moved from src/ to co-locate with the composite action
- .github/actions/release-commenter/action.yml: composite action extracted
from shared-release-branches.yml; cleaned up var/for-in patterns
- shared-auto-release.yml: 116-line inline script β 4-line composite action call
- shared-release-branches.yml: 131-line inline script β 2-line composite action call
- test/release-author-substitutor.test.js: updated import path
Co-authored-by: nitrocode <7775707+nitrocode@users.noreply.github.com>
---
.github/actions/release-commenter/action.yml | 107 +++++++++++++
.../actions/substitute-llm-authors/action.yml | 142 ++++++++++++++++++
.../release-author-substitutor.js | 0
.github/workflows/shared-auto-release.yml | 117 +--------------
.github/workflows/shared-release-branches.yml | 130 +---------------
test/release-author-substitutor.test.js | 2 +-
6 files changed, 256 insertions(+), 242 deletions(-)
create mode 100644 .github/actions/release-commenter/action.yml
create mode 100644 .github/actions/substitute-llm-authors/action.yml
rename {src => .github/actions/substitute-llm-authors}/release-author-substitutor.js (100%)
diff --git a/.github/actions/release-commenter/action.yml b/.github/actions/release-commenter/action.yml
new file mode 100644
index 00000000..629f6171
--- /dev/null
+++ b/.github/actions/release-commenter/action.yml
@@ -0,0 +1,107 @@
+name: "Release PR Commenter"
+description: >
+ Posts a comment on every pull request included in a release, linking back to the
+ release page. Compares the current release with the previous one to find all
+ associated PRs by walking the commit graph.
+
+runs:
+ using: composite
+ steps:
+ - name: Comment on released PRs
+ uses: actions/github-script@v7
+ with:
+ retries: 3
+ script: |
+ // Returns true only for the first occurrence of value in array (deduplication helper)
+ function onlyUnique(value, index, array) {
+ return array.indexOf(value) === index;
+ }
+
+ // Posts a release notification comment on a pull request
+ async function createCommentForPR(pr_id, release) {
+ const messageId = ``;
+ const message = `
+ ${messageId}
+ These changes were released in [${release.name}](${release.html_url}).
+ `;
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: pr_id,
+ body: message,
+ });
+ }
+
+ // Fetch the current release from the event payload
+ const releaseId = context.payload.release.id;
+
+ const currentReleaseResponse = await github.rest.repos.getRelease({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ release_id: releaseId,
+ });
+
+ const currentRelease = currentReleaseResponse.data;
+ const currentTag = currentRelease.tag_name;
+
+ // Find the previous release so we can compare commits
+ const releases = await github.rest.repos.listReleases({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ });
+
+ let previousRelease = null;
+ let currentReleaseFound = false;
+
+ for (const release of releases.data) {
+ if (currentReleaseFound) {
+ previousRelease = release;
+ break;
+ } else if (release.tag_name === currentTag) {
+ currentReleaseFound = true;
+ }
+ }
+
+ if (previousRelease === null) {
+ core.info(`No previous release found for ${currentTag}`);
+ return;
+ }
+
+ // Compare commits between current and previous release to find associated PRs
+ const commitsResponse = await github.rest.repos.compareCommits({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ base: previousRelease.tag_name,
+ head: currentRelease.tag_name,
+ });
+
+ const pull_requests = [];
+
+ for (const commit of commitsResponse.data.commits) {
+ const query = `
+ {
+ resource(url: "${context.payload.repository.html_url}/commit/${commit.sha}") {
+ ... on Commit {
+ messageHeadlineHTML
+ messageBodyHTML
+ associatedPullRequests(first: 10) {
+ pageInfo { hasNextPage }
+ edges { node { number } }
+ }
+ }
+ }
+ }
+ `;
+
+ const response = await github.graphql(query);
+
+ for (const edge of response.resource.associatedPullRequests.edges) {
+ pull_requests.push(edge.node.number);
+ }
+ }
+
+ // Post a release comment on each unique PR
+ for (const id of pull_requests.filter(onlyUnique)) {
+ await createCommentForPR(id, currentRelease);
+ }
diff --git a/.github/actions/substitute-llm-authors/action.yml b/.github/actions/substitute-llm-authors/action.yml
new file mode 100644
index 00000000..4114a96a
--- /dev/null
+++ b/.github/actions/substitute-llm-authors/action.yml
@@ -0,0 +1,142 @@
+name: "Substitute LLM Authors in Release Notes"
+description: >
+ Replaces LLM/AI bot author mentions in release notes with the PR's human assignee.
+ When a PR is authored by an AI assistant (e.g. GitHub Copilot), this action substitutes
+ the bot username with the human assignee so they receive proper credit in the changelog.
+ Logic mirrors the unit-tested module at release-author-substitutor.js.
+
+inputs:
+ token:
+ description: "GitHub token with repo read/write access for release and PR APIs"
+ required: true
+ release-id:
+ description: "The numeric ID of the release to update (from the release-drafter output)"
+ required: true
+ llm-users:
+ description: >
+ JSON array of LLM/AI bot GitHub usernames whose PR authorship should be replaced
+ with the PR's human assignee in the release notes.
+ Example: '["github-copilot[bot]","devin-ai-integration[bot]"]'
+ Glob patterns with '*' are supported for flexible matching.
+ Defaults to a built-in list that includes GitHub Copilot and other common AI assistants.
+ required: false
+ default: ''
+
+runs:
+ using: composite
+ steps:
+ - name: Substitute LLM authors
+ uses: actions/github-script@v7
+ env:
+ LLM_USERS: ${{ inputs.llm-users }}
+ RELEASE_ID: ${{ inputs.release-id }}
+ with:
+ github-token: ${{ inputs.token }}
+ retries: 3
+ script: |
+ // Default list of LLM/AI assistant usernames.
+ // Logic mirrors the unit-tested module at release-author-substitutor.js.
+ const DEFAULT_LLM_USERS = [
+ 'github-copilot[bot]',
+ 'copilot',
+ 'devin-ai-integration[bot]',
+ 'amazon-q[bot]',
+ 'codeium[bot]',
+ ];
+
+ const llmUsersInput = process.env.LLM_USERS;
+ const llmUsers = (llmUsersInput && llmUsersInput.trim())
+ ? JSON.parse(llmUsersInput)
+ : DEFAULT_LLM_USERS;
+
+ function isLLMUser(username) {
+ const norm = username.toLowerCase();
+ return llmUsers.some(llmUser => {
+ const nu = llmUser.toLowerCase();
+ if (nu.includes('*')) {
+ const regexStr = nu.split('*').map(s => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*');
+ return new RegExp(`^${regexStr}$`).test(norm);
+ }
+ return norm === nu;
+ });
+ }
+
+ function extractPRReferences(body) {
+ const prPattern = /@([a-zA-Z0-9_.\-[\]]+)\s+\(#(\d+)\)/g;
+ const matches = [];
+ let match;
+ // fullMatch is omitted here (unused); see release-author-substitutor.js for full shape
+ while ((match = prPattern.exec(body)) !== null) {
+ matches.push({ author: match[1], prNumber: parseInt(match[2], 10) });
+ }
+ return matches;
+ }
+
+ function substituteLLMAuthors(body, prSubstitutions) {
+ let updatedBody = body;
+ for (const { prNumber, oldAuthor, newAuthor } of prSubstitutions) {
+ const escapedAuthor = oldAuthor.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
+ const pattern = new RegExp(`@${escapedAuthor}(\\s+\\(#${prNumber}\\))`, 'g');
+ updatedBody = updatedBody.replace(pattern, `@${newAuthor}$1`);
+ }
+ return updatedBody;
+ }
+
+ const releaseId = parseInt(process.env.RELEASE_ID, 10);
+
+ const { data: release } = await github.rest.repos.getRelease({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ release_id: releaseId,
+ });
+
+ const body = release.body || '';
+ const prRefs = extractPRReferences(body);
+ const llmPRRefs = prRefs.filter(ref => isLLMUser(ref.author));
+
+ if (llmPRRefs.length === 0) {
+ core.info('No LLM-authored PRs found in release notes');
+ return;
+ }
+
+ const prSubstitutions = [];
+
+ for (const prRef of llmPRRefs) {
+ try {
+ const { data: pr } = await github.rest.pulls.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: prRef.prNumber,
+ });
+
+ const humanAssignee = (pr.assignees || []).find(a => !isLLMUser(a.login));
+ if (humanAssignee) {
+ prSubstitutions.push({
+ prNumber: prRef.prNumber,
+ oldAuthor: prRef.author,
+ newAuthor: humanAssignee.login,
+ });
+ core.info(`PR #${prRef.prNumber}: substituting @${prRef.author} with @${humanAssignee.login}`);
+ } else {
+ core.info(`PR #${prRef.prNumber}: no human assignee found for @${prRef.author}, keeping original`);
+ }
+ } catch (error) {
+ core.warning(`Failed to get assignees for PR #${prRef.prNumber}: ${error.message}`);
+ }
+ }
+
+ if (prSubstitutions.length === 0) {
+ core.info('No substitutions needed');
+ return;
+ }
+
+ const updatedBody = substituteLLMAuthors(body, prSubstitutions);
+
+ await github.rest.repos.updateRelease({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ release_id: releaseId,
+ body: updatedBody,
+ });
+
+ core.info(`Release notes updated: ${prSubstitutions.length} LLM author(s) replaced with human assignee(s)`);
diff --git a/src/release-author-substitutor.js b/.github/actions/substitute-llm-authors/release-author-substitutor.js
similarity index 100%
rename from src/release-author-substitutor.js
rename to .github/actions/substitute-llm-authors/release-author-substitutor.js
diff --git a/.github/workflows/shared-auto-release.yml b/.github/workflows/shared-auto-release.yml
index ee214598..38835a46 100644
--- a/.github/workflows/shared-auto-release.yml
+++ b/.github/workflows/shared-auto-release.yml
@@ -140,122 +140,13 @@ jobs:
# When a PR was authored by an LLM/AI assistant, replace the bot username with the
# PR's human assignee in the release notes so humans get proper credit.
- # The utility functions below mirror src/release-author-substitutor.js (unit-tested).
- name: Substitute LLM authors in release notes
if: ${{ steps.drafter.outputs.id != '' }}
- uses: actions/github-script@v7
- env:
- LLM_USERS: ${{ inputs.llm-users }}
+ uses: ./.github/actions/substitute-llm-authors
with:
- github-token: ${{ steps.github-app.outputs.token }}
- retries: 3
- script: |
- // Default list of LLM/AI assistant usernames.
- // Mirrors src/release-author-substitutor.js DEFAULT_LLM_USERS.
- const DEFAULT_LLM_USERS = [
- 'github-copilot[bot]',
- 'copilot',
- 'devin-ai-integration[bot]',
- 'amazon-q[bot]',
- 'codeium[bot]',
- ];
-
- const llmUsersInput = process.env.LLM_USERS;
- const llmUsers = (llmUsersInput && llmUsersInput.trim())
- ? JSON.parse(llmUsersInput)
- : DEFAULT_LLM_USERS;
-
- function isLLMUser(username) {
- const norm = username.toLowerCase();
- return llmUsers.some(llmUser => {
- const nu = llmUser.toLowerCase();
- if (nu.includes('*')) {
- const regexStr = nu.split('*').map(s => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*');
- return new RegExp(`^${regexStr}$`).test(norm);
- }
- return norm === nu;
- });
- }
-
- function extractPRReferences(body) {
- const prPattern = /@([a-zA-Z0-9_.\-[\]]+)\s+\(#(\d+)\)/g;
- const matches = [];
- let match;
- while ((match = prPattern.exec(body)) !== null) {
- // fullMatch is omitted here (unused); see src/release-author-substitutor.js for full shape
- matches.push({ author: match[1], prNumber: parseInt(match[2], 10) });
- }
- return matches;
- }
-
- function substituteLLMAuthors(body, prSubstitutions) {
- let updatedBody = body;
- for (const { prNumber, oldAuthor, newAuthor } of prSubstitutions) {
- const escapedAuthor = oldAuthor.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
- const pattern = new RegExp(`@${escapedAuthor}(\\s+\\(#${prNumber}\\))`, 'g');
- updatedBody = updatedBody.replace(pattern, `@${newAuthor}$1`);
- }
- return updatedBody;
- }
-
- const releaseId = parseInt('${{ steps.drafter.outputs.id }}', 10);
-
- const { data: release } = await github.rest.repos.getRelease({
- owner: context.repo.owner,
- repo: context.repo.repo,
- release_id: releaseId,
- });
-
- const body = release.body || '';
- const prRefs = extractPRReferences(body);
- const llmPRRefs = prRefs.filter(ref => isLLMUser(ref.author));
-
- if (llmPRRefs.length === 0) {
- core.info('No LLM-authored PRs found in release notes');
- return;
- }
-
- const prSubstitutions = [];
-
- for (const prRef of llmPRRefs) {
- try {
- const { data: pr } = await github.rest.pulls.get({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: prRef.prNumber,
- });
-
- const humanAssignee = (pr.assignees || []).find(a => !isLLMUser(a.login));
- if (humanAssignee) {
- prSubstitutions.push({
- prNumber: prRef.prNumber,
- oldAuthor: prRef.author,
- newAuthor: humanAssignee.login,
- });
- core.info(`PR #${prRef.prNumber}: substituting @${prRef.author} with @${humanAssignee.login}`);
- } else {
- core.info(`PR #${prRef.prNumber}: no human assignee found for @${prRef.author}, keeping original`);
- }
- } catch (error) {
- core.warning(`Failed to get assignees for PR #${prRef.prNumber}: ${error.message}`);
- }
- }
-
- if (prSubstitutions.length === 0) {
- core.info('No substitutions needed');
- return;
- }
-
- const updatedBody = substituteLLMAuthors(body, prSubstitutions);
-
- await github.rest.repos.updateRelease({
- owner: context.repo.owner,
- repo: context.repo.repo,
- release_id: releaseId,
- body: updatedBody,
- });
-
- core.info(`Release notes updated: ${prSubstitutions.length} LLM author(s) replaced with human assignee(s)`);
+ token: ${{ steps.github-app.outputs.token }}
+ release-id: ${{ steps.drafter.outputs.id }}
+ llm-users: ${{ inputs.llm-users }}
# - uses: actions/github-script@v7
# if: ${{ steps.context.outputs.config == 'auto-feature-release.yml' && steps.drafter.outputs.id != '' }}
diff --git a/.github/workflows/shared-release-branches.yml b/.github/workflows/shared-release-branches.yml
index c32185dd..052d1a96 100644
--- a/.github/workflows/shared-release-branches.yml
+++ b/.github/workflows/shared-release-branches.yml
@@ -48,131 +48,5 @@ jobs:
release-commenter:
runs-on: ${{ fromJSON(inputs.runs-on) }}
steps:
- - uses: actions/github-script@v7
- with:
- result-encoding: string
- retries: 3
- script: |
- // Function to check if a value is unique in an array
- function onlyUnique(value, index, array) {
- return array.indexOf(value) === index;
- }
-
- // Function to create or update a comment for a pull request (PR) associated with a release
- async function createCommentForPR(pr_id, release) {
- // Parameters for fetching comments related to the PR
- const parameters = {
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: pr_id,
- per_page: 100,
- }
-
- // Constructing the message to be posted or updated as a comment
- const messageId = ``;
- const message = `
- ${messageId}
- These changes were released in [${release.name}](${release.html_url}).
- `;
-
- // Π‘reate a new comment
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: pr_id,
- body: message
- });
- }
-
- // Retrieving the ID of the current release
- release_id = context.payload.release.id;
-
- // Fetching details of the current release
- currentReleaseResponse = await github.rest.repos.getRelease({
- owner: context.repo.owner,
- repo: context.repo.repo,
- release_id,
- });
-
- currentRelease = currentReleaseResponse.data;
-
- // Extracting tag name and target branch from the current release
- currentTag = currentRelease.tag_name;
- currentBranch = currentRelease.target_commitish;
-
- // Listing all releases of the repository
- releases = await github.rest.repos.listReleases({
- owner: context.repo.owner,
- repo: context.repo.repo,
- });
-
- // Initializing variables for storing information about the previous release
- previousRelease = null;
- currentReleaseFound = false;
-
- // Iterating through releases to find the previous release relative to the current one
- for (release of releases.data) {
- if (currentReleaseFound) {
- previousRelease = release;
- break;
- } else if (release.tag_name == currentTag) {
- currentReleaseFound = true;
- }
- }
-
- // If no previous release is found, log a message and return
- if (previousRelease == null) {
- console.log(`No previous release found for ${currentTag}`);
- return;
- }
-
- // Comparing commits between the current and previous releases
- commitsResponse = await github.rest.repos.compareCommits({
- owner: context.repo.owner,
- repo: context.repo.repo,
- base: previousRelease.tag_name,
- head: currentRelease.tag_name,
- });
-
- commits = commitsResponse.data;
-
- // Initializing an array to store pull request numbers associated with the commits
- pull_requests = [];
-
- // Iterating through commits to find associated pull requests and extracting their numbers
- for (commit of commits.commits) {
- responseCommit = await github.rest.git.getCommit({
- owner: context.repo.owner,
- repo: context.repo.repo,
- commit_sha: commit.sha,
- });
-
- // GraphQL query to fetch details about the commit, including associated pull requests
- const query = `
- {
- resource(url: "${context.payload.repository.html_url}/commit/${commit.sha}") {
- ... on Commit {
- messageHeadlineHTML
- messageBodyHTML
- associatedPullRequests(first: 10) {
- pageInfo { hasNextPage }
- edges { node { number } }
- }
- }
- }
- }
- `;
-
- response = await github.graphql(query);
-
- // Extracting pull request numbers from the GraphQL response
- for (edge of response.resource.associatedPullRequests.edges) {
- pull_requests.push(edge.node.number);
- }
- }
-
- // Iterating through unique pull request numbers and creating or updating comments for them
- for (id of pull_requests.filter(onlyUnique)) {
- await createCommentForPR(id, currentRelease);
- }
-
+ - name: Comment on released PRs
+ uses: ./.github/actions/release-commenter
diff --git a/test/release-author-substitutor.test.js b/test/release-author-substitutor.test.js
index eb4353db..df832cb5 100644
--- a/test/release-author-substitutor.test.js
+++ b/test/release-author-substitutor.test.js
@@ -8,7 +8,7 @@ const {
isLLMUser,
extractPRReferences,
substituteLLMAuthors,
-} = require('../src/release-author-substitutor');
+} = require('../.github/actions/substitute-llm-authors/release-author-substitutor');
describe('DEFAULT_LLM_USERS', () => {
it('is a non-empty array', () => {