From 0300491136c99cf106bf52ce0fa375255fed934d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 08:41:36 +0000 Subject: [PATCH 1/5] Initial plan From def3e52d1327b1de492d6df121d3ae21a0a41174 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 08:48:13 +0000 Subject: [PATCH 2/5] feat(release): add summarize changelog action Co-authored-by: neilime <314088+neilime@users.noreply.github.com> --- README.md | 2 + actions/release/summarize-changelog/README.md | 50 ++++++ .../release/summarize-changelog/action.yml | 150 ++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 actions/release/summarize-changelog/README.md create mode 100644 actions/release/summarize-changelog/action.yml diff --git a/README.md b/README.md index ba9d1816..53cffd75 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ _Actions for managing releases._ #### - [Create](actions/release/create/README.md) +#### - [Summarize changelog](actions/release/summarize-changelog/README.md) + ### Workflow _Actions for managing workflows._ diff --git a/actions/release/summarize-changelog/README.md b/actions/release/summarize-changelog/README.md new file mode 100644 index 00000000..e0f39cf9 --- /dev/null +++ b/actions/release/summarize-changelog/README.md @@ -0,0 +1,50 @@ +# GitHub Action: Release - Summarize Changelog + +## Overview + +Compile a release changelog from all commits between two refs. + +Features: + +- Supports conventional commit grouping. +- Supports configurable Markdown templates. +- Can inject a summary generated by any LLM provider (`llm-summary` input). +- Exposes a provider-agnostic `llm-prompt` output to integrate with any LLM action. + +## Usage + +```yaml +- id: changelog + uses: hoverkraft-tech/ci-github-publish/actions/release/summarize-changelog@main + with: + base-ref: v1.2.0 + head-ref: HEAD + conventional-commits: "true" + llm-summary: "" + markdown-template: | + ## Release notes + Range: `{{base_ref}}..{{head_ref}}` + + {{summary}} + + {{changes}} +``` + +## Inputs + +| Input | Description | Required | Default | +| ---------------------- | -------------------------------------------------------------- | -------- | ----------------- | +| `base-ref` | Base Git ref (excluded from the range). | true | - | +| `head-ref` | Head Git ref (included in the range). | true | - | +| `conventional-commits` | Group commit messages by conventional commit type. | false | `true` | +| `llm-summary` | Optional summary generated by any LLM provider. | false | `""` | +| `markdown-template` | Markdown template with placeholders (`base_ref`, `changes`, …) | false | Built-in template | + +## Outputs + +| Output | Description | +| -------------- | --------------------------------------------------- | +| `changelog` | Rendered Markdown changelog. | +| `changes` | Compiled Markdown list of changes. | +| `llm-prompt` | Prompt text that can be sent to any LLM provider. | +| `commit-count` | Number of commits included in the changelog output. | diff --git a/actions/release/summarize-changelog/action.yml b/actions/release/summarize-changelog/action.yml new file mode 100644 index 00000000..f7ac512e --- /dev/null +++ b/actions/release/summarize-changelog/action.yml @@ -0,0 +1,150 @@ +name: "Release - Summarize changelog" +description: "Compile release changelog entries between two refs, with optional conventional commit grouping and LLM summary injection." +author: hoverkraft +branding: + icon: file-text + color: blue + +inputs: + base-ref: + description: "Base git ref (excluded from the range)." + required: true + head-ref: + description: "Head git ref (included in the range)." + required: true + conventional-commits: + description: "Whether to group commit messages by conventional commit type." + required: false + default: "true" + llm-summary: + description: "Optional summary generated by any LLM provider." + required: false + default: "" + markdown-template: + description: | + Markdown template used to build the final changelog. + Supported placeholders: + - {{base_ref}} + - {{head_ref}} + - {{commit_count}} + - {{summary}} + - {{changes}} + required: false + default: | + ## Changelog + + _Changes from `{{base_ref}}` to `{{head_ref}}`._ + + {{summary}} + + {{changes}} + +outputs: + changelog: + description: "Rendered markdown changelog." + value: ${{ steps.summarize.outputs.changelog }} + changes: + description: "Compiled markdown list of changes." + value: ${{ steps.summarize.outputs.changes }} + llm-prompt: + description: "Prompt text that can be sent to any LLM provider." + value: ${{ steps.summarize.outputs.llm-prompt }} + commit-count: + description: "Number of commits included in the changelog." + value: ${{ steps.summarize.outputs.commit-count }} + +runs: + using: "composite" + steps: + - uses: hoverkraft-tech/ci-github-common/actions/checkout@f24ce3360a8abf9bf386a62ab13d0ae5de5f9d13 # 0.31.7 + with: + fetch-depth: "0" + + - id: summarize + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + BASE_REF: ${{ inputs.base-ref }} + HEAD_REF: ${{ inputs.head-ref }} + CONVENTIONAL_COMMITS: ${{ inputs.conventional-commits }} + LLM_SUMMARY: ${{ inputs.llm-summary }} + MARKDOWN_TEMPLATE: ${{ inputs.markdown-template }} + with: + script: | + const { execSync } = require("node:child_process"); + + const baseRef = process.env.BASE_REF.trim(); + const headRef = process.env.HEAD_REF.trim(); + const useConventionalCommits = process.env.CONVENTIONAL_COMMITS.trim() === "true"; + const llmSummary = process.env.LLM_SUMMARY.trim(); + const markdownTemplate = process.env.MARKDOWN_TEMPLATE; + + if (!baseRef || !headRef) { + core.setFailed("Both base-ref and head-ref inputs are required."); + return; + } + + const rawLog = execSync( + `git log --no-merges --pretty=format:%s ${baseRef}..${headRef}`, + { encoding: "utf8" }, + ).trim(); + + const commits = rawLog ? rawLog.split("\n").map((line) => line.trim()).filter(Boolean) : []; + const groupedCommits = commits.reduce((acc, subject) => { + const match = subject.match(/^(?[a-z]+)(?:\([^)]+\))?(?:!)?:\s+(?.+)$/i); + const rawType = match?.groups?.type?.toLowerCase(); + const description = match?.groups?.description || subject; + + let section = "Other changes"; + if (useConventionalCommits && rawType) { + const typeToTitle = { + feat: "Features", + fix: "Bug fixes", + perf: "Performance", + refactor: "Refactors", + docs: "Documentation", + test: "Tests", + build: "Build", + ci: "CI", + chore: "Chores", + style: "Style", + revert: "Reverts", + }; + section = typeToTitle[rawType] || "Other changes"; + } else if (!useConventionalCommits) { + section = "Changes"; + } + + if (!acc.has(section)) { + acc.set(section, []); + } + + acc.get(section).push(useConventionalCommits ? description : subject); + return acc; + }, new Map()); + + const changes = [...groupedCommits.entries()] + .map(([section, entries]) => `### ${section}\n${entries.map((entry) => `- ${entry}`).join("\n")}`) + .join("\n\n") + .trim(); + + const summary = llmSummary ? `### Summary\n${llmSummary}` : ""; + const changelog = markdownTemplate + .replace(/\{\{base_ref\}\}/g, baseRef) + .replace(/\{\{head_ref\}\}/g, headRef) + .replace(/\{\{commit_count\}\}/g, `${commits.length}`) + .replace(/\{\{summary\}\}/g, summary) + .replace(/\{\{changes\}\}/g, changes) + .trim(); + + const llmPrompt = [ + "Summarize the following release changes as markdown:", + `Base ref: ${baseRef}`, + `Head ref: ${headRef}`, + "", + changes || "- No user-facing changes found.", + ].join("\n"); + + core.setOutput("commit-count", `${commits.length}`); + core.setOutput("changes", changes); + core.setOutput("llm-prompt", llmPrompt); + core.setOutput("changelog", changelog); From 9ebc381775f2003802eb92706b7963e5717fa44c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:03:25 +0000 Subject: [PATCH 3/5] feat(release): support llm-prompt-based summary generation Co-authored-by: neilime <314088+neilime@users.noreply.github.com> --- actions/release/summarize-changelog/README.md | 17 ++++++---- .../release/summarize-changelog/action.yml | 32 +++++++++++++------ 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/actions/release/summarize-changelog/README.md b/actions/release/summarize-changelog/README.md index e0f39cf9..07de1299 100644 --- a/actions/release/summarize-changelog/README.md +++ b/actions/release/summarize-changelog/README.md @@ -9,6 +9,7 @@ Features: - Supports conventional commit grouping. - Supports configurable Markdown templates. - Can inject a summary generated by any LLM provider (`llm-summary` input). +- Can generate the summary from the produced `llm-prompt` via a custom command (`llm-summary-command` input). - Exposes a provider-agnostic `llm-prompt` output to integrate with any LLM action. ## Usage @@ -21,6 +22,7 @@ Features: head-ref: HEAD conventional-commits: "true" llm-summary: "" + llm-summary-command: "" markdown-template: | ## Release notes Range: `{{base_ref}}..{{head_ref}}` @@ -32,13 +34,14 @@ Features: ## Inputs -| Input | Description | Required | Default | -| ---------------------- | -------------------------------------------------------------- | -------- | ----------------- | -| `base-ref` | Base Git ref (excluded from the range). | true | - | -| `head-ref` | Head Git ref (included in the range). | true | - | -| `conventional-commits` | Group commit messages by conventional commit type. | false | `true` | -| `llm-summary` | Optional summary generated by any LLM provider. | false | `""` | -| `markdown-template` | Markdown template with placeholders (`base_ref`, `changes`, …) | false | Built-in template | +| Input | Description | Required | Default | +| ---------------------- | ---------------------------------------------------------------------------------- | -------- | ----------------- | +| `base-ref` | Base Git ref (excluded from the range). | true | - | +| `head-ref` | Head Git ref (included in the range). | true | - | +| `conventional-commits` | Group commit messages by conventional commit type. | false | `true` | +| `llm-summary` | Optional summary generated by any LLM provider. | false | `""` | +| `llm-summary-command` | Optional command that reads `llm-prompt` on stdin and returns a summary on stdout. | false | `""` | +| `markdown-template` | Markdown template with placeholders (`base_ref`, `changes`, …) | false | Built-in template | ## Outputs diff --git a/actions/release/summarize-changelog/action.yml b/actions/release/summarize-changelog/action.yml index f7ac512e..9826c541 100644 --- a/actions/release/summarize-changelog/action.yml +++ b/actions/release/summarize-changelog/action.yml @@ -20,6 +20,10 @@ inputs: description: "Optional summary generated by any LLM provider." required: false default: "" + llm-summary-command: + description: "Optional command used to generate the summary from `llm-prompt` (prompt is sent through stdin)." + required: false + default: "" markdown-template: description: | Markdown template used to build the final changelog. @@ -67,6 +71,7 @@ runs: HEAD_REF: ${{ inputs.head-ref }} CONVENTIONAL_COMMITS: ${{ inputs.conventional-commits }} LLM_SUMMARY: ${{ inputs.llm-summary }} + LLM_SUMMARY_COMMAND: ${{ inputs.llm-summary-command }} MARKDOWN_TEMPLATE: ${{ inputs.markdown-template }} with: script: | @@ -75,7 +80,8 @@ runs: const baseRef = process.env.BASE_REF.trim(); const headRef = process.env.HEAD_REF.trim(); const useConventionalCommits = process.env.CONVENTIONAL_COMMITS.trim() === "true"; - const llmSummary = process.env.LLM_SUMMARY.trim(); + const llmSummaryInput = process.env.LLM_SUMMARY.trim(); + const llmSummaryCommand = process.env.LLM_SUMMARY_COMMAND.trim(); const markdownTemplate = process.env.MARKDOWN_TEMPLATE; if (!baseRef || !headRef) { @@ -127,6 +133,22 @@ runs: .join("\n\n") .trim(); + const llmPrompt = [ + "Summarize the following release changes as markdown:", + `Base ref: ${baseRef}`, + `Head ref: ${headRef}`, + "", + changes || "- No user-facing changes found.", + ].join("\n"); + + let llmSummary = llmSummaryInput; + if (!llmSummary && llmSummaryCommand) { + llmSummary = execSync(llmSummaryCommand, { + encoding: "utf8", + input: llmPrompt, + }).trim(); + } + const summary = llmSummary ? `### Summary\n${llmSummary}` : ""; const changelog = markdownTemplate .replace(/\{\{base_ref\}\}/g, baseRef) @@ -136,14 +158,6 @@ runs: .replace(/\{\{changes\}\}/g, changes) .trim(); - const llmPrompt = [ - "Summarize the following release changes as markdown:", - `Base ref: ${baseRef}`, - `Head ref: ${headRef}`, - "", - changes || "- No user-facing changes found.", - ].join("\n"); - core.setOutput("commit-count", `${commits.length}`); core.setOutput("changes", changes); core.setOutput("llm-prompt", llmPrompt); From bdfa6392c0e0d76ede0783e1e96873d94f7920bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:31:41 +0000 Subject: [PATCH 4/5] feat(release): use npm openai package for llm summary generation Co-authored-by: neilime <314088+neilime@users.noreply.github.com> --- actions/release/summarize-changelog/README.md | 23 +-- .../release/summarize-changelog/action.yml | 108 +++------------ .../release/summarize-changelog/summarize.js | 131 ++++++++++++++++++ 3 files changed, 167 insertions(+), 95 deletions(-) create mode 100644 actions/release/summarize-changelog/summarize.js diff --git a/actions/release/summarize-changelog/README.md b/actions/release/summarize-changelog/README.md index 07de1299..e09cdc27 100644 --- a/actions/release/summarize-changelog/README.md +++ b/actions/release/summarize-changelog/README.md @@ -9,6 +9,7 @@ Features: - Supports conventional commit grouping. - Supports configurable Markdown templates. - Can inject a summary generated by any LLM provider (`llm-summary` input). +- Can generate summary directly through an OpenAI-compatible API using the `openai` npm package. - Can generate the summary from the produced `llm-prompt` via a custom command (`llm-summary-command` input). - Exposes a provider-agnostic `llm-prompt` output to integrate with any LLM action. @@ -22,6 +23,9 @@ Features: head-ref: HEAD conventional-commits: "true" llm-summary: "" + llm-model: "gpt-4o-mini" + llm-api-key: ${{ secrets.OPENAI_API_KEY }} + llm-base-url: "https://api.openai.com/v1" llm-summary-command: "" markdown-template: | ## Release notes @@ -34,14 +38,17 @@ Features: ## Inputs -| Input | Description | Required | Default | -| ---------------------- | ---------------------------------------------------------------------------------- | -------- | ----------------- | -| `base-ref` | Base Git ref (excluded from the range). | true | - | -| `head-ref` | Head Git ref (included in the range). | true | - | -| `conventional-commits` | Group commit messages by conventional commit type. | false | `true` | -| `llm-summary` | Optional summary generated by any LLM provider. | false | `""` | -| `llm-summary-command` | Optional command that reads `llm-prompt` on stdin and returns a summary on stdout. | false | `""` | -| `markdown-template` | Markdown template with placeholders (`base_ref`, `changes`, …) | false | Built-in template | +| Input | Description | Required | Default | +| ---------------------- | ---------------------------------------------------------------------------------- | -------- | --------------------------- | +| `base-ref` | Base Git ref (excluded from the range). | true | - | +| `head-ref` | Head Git ref (included in the range). | true | - | +| `conventional-commits` | Group commit messages by conventional commit type. | false | `true` | +| `llm-summary` | Optional summary generated by any LLM provider. | false | `""` | +| `llm-model` | Optional OpenAI-compatible model used to generate summary from `llm-prompt`. | false | `""` | +| `llm-api-key` | Optional API key for the OpenAI-compatible model provider. | false | `""` | +| `llm-base-url` | Optional OpenAI-compatible API base URL. | false | `https://api.openai.com/v1` | +| `llm-summary-command` | Optional command that reads `llm-prompt` on stdin and returns a summary on stdout. | false | `""` | +| `markdown-template` | Markdown template with placeholders (`base_ref`, `changes`, …) | false | Built-in template | ## Outputs diff --git a/actions/release/summarize-changelog/action.yml b/actions/release/summarize-changelog/action.yml index 9826c541..4cfa9603 100644 --- a/actions/release/summarize-changelog/action.yml +++ b/actions/release/summarize-changelog/action.yml @@ -20,6 +20,18 @@ inputs: description: "Optional summary generated by any LLM provider." required: false default: "" + llm-model: + description: "Optional OpenAI-compatible model used to generate summary from `llm-prompt`." + required: false + default: "" + llm-api-key: + description: "Optional API key for the OpenAI-compatible model provider." + required: false + default: "" + llm-base-url: + description: "Optional OpenAI-compatible API base URL." + required: false + default: "https://api.openai.com/v1" llm-summary-command: description: "Optional command used to generate the summary from `llm-prompt` (prompt is sent through stdin)." required: false @@ -64,6 +76,10 @@ runs: with: fetch-depth: "0" + - shell: bash + if: ${{ !inputs.llm-summary && inputs.llm-model != '' }} + run: npm install --no-save openai@6.22.0 + - id: summarize uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: @@ -71,94 +87,12 @@ runs: HEAD_REF: ${{ inputs.head-ref }} CONVENTIONAL_COMMITS: ${{ inputs.conventional-commits }} LLM_SUMMARY: ${{ inputs.llm-summary }} + LLM_MODEL: ${{ inputs.llm-model }} + LLM_API_KEY: ${{ inputs.llm-api-key }} + LLM_BASE_URL: ${{ inputs.llm-base-url }} LLM_SUMMARY_COMMAND: ${{ inputs.llm-summary-command }} MARKDOWN_TEMPLATE: ${{ inputs.markdown-template }} with: script: | - const { execSync } = require("node:child_process"); - - const baseRef = process.env.BASE_REF.trim(); - const headRef = process.env.HEAD_REF.trim(); - const useConventionalCommits = process.env.CONVENTIONAL_COMMITS.trim() === "true"; - const llmSummaryInput = process.env.LLM_SUMMARY.trim(); - const llmSummaryCommand = process.env.LLM_SUMMARY_COMMAND.trim(); - const markdownTemplate = process.env.MARKDOWN_TEMPLATE; - - if (!baseRef || !headRef) { - core.setFailed("Both base-ref and head-ref inputs are required."); - return; - } - - const rawLog = execSync( - `git log --no-merges --pretty=format:%s ${baseRef}..${headRef}`, - { encoding: "utf8" }, - ).trim(); - - const commits = rawLog ? rawLog.split("\n").map((line) => line.trim()).filter(Boolean) : []; - const groupedCommits = commits.reduce((acc, subject) => { - const match = subject.match(/^(?[a-z]+)(?:\([^)]+\))?(?:!)?:\s+(?.+)$/i); - const rawType = match?.groups?.type?.toLowerCase(); - const description = match?.groups?.description || subject; - - let section = "Other changes"; - if (useConventionalCommits && rawType) { - const typeToTitle = { - feat: "Features", - fix: "Bug fixes", - perf: "Performance", - refactor: "Refactors", - docs: "Documentation", - test: "Tests", - build: "Build", - ci: "CI", - chore: "Chores", - style: "Style", - revert: "Reverts", - }; - section = typeToTitle[rawType] || "Other changes"; - } else if (!useConventionalCommits) { - section = "Changes"; - } - - if (!acc.has(section)) { - acc.set(section, []); - } - - acc.get(section).push(useConventionalCommits ? description : subject); - return acc; - }, new Map()); - - const changes = [...groupedCommits.entries()] - .map(([section, entries]) => `### ${section}\n${entries.map((entry) => `- ${entry}`).join("\n")}`) - .join("\n\n") - .trim(); - - const llmPrompt = [ - "Summarize the following release changes as markdown:", - `Base ref: ${baseRef}`, - `Head ref: ${headRef}`, - "", - changes || "- No user-facing changes found.", - ].join("\n"); - - let llmSummary = llmSummaryInput; - if (!llmSummary && llmSummaryCommand) { - llmSummary = execSync(llmSummaryCommand, { - encoding: "utf8", - input: llmPrompt, - }).trim(); - } - - const summary = llmSummary ? `### Summary\n${llmSummary}` : ""; - const changelog = markdownTemplate - .replace(/\{\{base_ref\}\}/g, baseRef) - .replace(/\{\{head_ref\}\}/g, headRef) - .replace(/\{\{commit_count\}\}/g, `${commits.length}`) - .replace(/\{\{summary\}\}/g, summary) - .replace(/\{\{changes\}\}/g, changes) - .trim(); - - core.setOutput("commit-count", `${commits.length}`); - core.setOutput("changes", changes); - core.setOutput("llm-prompt", llmPrompt); - core.setOutput("changelog", changelog); + const summarize = require(`${process.env.GITHUB_ACTION_PATH}/summarize.js`); + await summarize({ core }); diff --git a/actions/release/summarize-changelog/summarize.js b/actions/release/summarize-changelog/summarize.js new file mode 100644 index 00000000..752e1f0f --- /dev/null +++ b/actions/release/summarize-changelog/summarize.js @@ -0,0 +1,131 @@ +const { execSync } = require("node:child_process"); + +module.exports = async ({ core }) => { + const baseRef = process.env.BASE_REF.trim(); + const headRef = process.env.HEAD_REF.trim(); + const useConventionalCommits = + process.env.CONVENTIONAL_COMMITS.trim() === "true"; + const llmSummaryInput = process.env.LLM_SUMMARY.trim(); + const llmModel = process.env.LLM_MODEL.trim(); + const llmApiKey = process.env.LLM_API_KEY.trim(); + const llmBaseUrl = process.env.LLM_BASE_URL.trim(); + const llmSummaryCommand = process.env.LLM_SUMMARY_COMMAND.trim(); + const markdownTemplate = process.env.MARKDOWN_TEMPLATE; + + if (!baseRef || !headRef) { + core.setFailed("Both base-ref and head-ref inputs are required."); + return; + } + + const rawLog = execSync( + `git log --no-merges --pretty=format:%s ${baseRef}..${headRef}`, + { + encoding: "utf8", + }, + ).trim(); + + const commits = rawLog + ? rawLog + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + : []; + const groupedCommits = commits.reduce((acc, subject) => { + const match = subject.match( + /^(?[a-z]+)(?:\([^)]+\))?(?:!)?:\s+(?.+)$/i, + ); + const rawType = match?.groups?.type?.toLowerCase(); + const description = match?.groups?.description || subject; + + let section = "Other changes"; + if (useConventionalCommits && rawType) { + const typeToTitle = { + feat: "Features", + fix: "Bug fixes", + perf: "Performance", + refactor: "Refactors", + docs: "Documentation", + test: "Tests", + build: "Build", + ci: "CI", + chore: "Chores", + style: "Style", + revert: "Reverts", + }; + section = typeToTitle[rawType] || "Other changes"; + } else if (!useConventionalCommits) { + section = "Changes"; + } + + if (!acc.has(section)) { + acc.set(section, []); + } + + acc.get(section).push(useConventionalCommits ? description : subject); + return acc; + }, new Map()); + + const changes = [...groupedCommits.entries()] + .map( + ([section, entries]) => + `### ${section}\n${entries.map((entry) => `- ${entry}`).join("\n")}`, + ) + .join("\n\n") + .trim(); + + const llmPrompt = [ + "Summarize the following release changes as markdown:", + `Base ref: ${baseRef}`, + `Head ref: ${headRef}`, + "", + changes || "- No user-facing changes found.", + ].join("\n"); + + let llmSummary = llmSummaryInput; + if (!llmSummary && llmModel) { + if (!llmApiKey) { + core.setFailed("llm-api-key is required when llm-model is provided."); + return; + } + + const OpenAI = require("openai"); + const openai = new OpenAI({ + apiKey: llmApiKey, + baseURL: llmBaseUrl || undefined, + }); + + const completion = await openai.chat.completions.create({ + model: llmModel, + messages: [ + { + role: "system", + content: "You generate concise release summaries in Markdown.", + }, + { role: "user", content: llmPrompt }, + ], + }); + + llmSummary = completion.choices?.[0]?.message?.content?.trim() || ""; + } + + if (!llmSummary && llmSummaryCommand) { + llmSummary = execSync(llmSummaryCommand, { + encoding: "utf8", + input: llmPrompt, + }).trim(); + } + + const summary = llmSummary ? `### Summary\n${llmSummary}` : ""; + const changelog = markdownTemplate + .replace(/\{\{base_ref\}\}/g, baseRef) + .replace(/\{\{head_ref\}\}/g, headRef) + .replace(/\{\{commit_count\}\}/g, `${commits.length}`) + .replace(/\{\{summary\}\}/g, summary) + .replace(/\{\{changes\}\}/g, changes) + .trim(); + + core.setOutput("commit-count", `${commits.length}`); + core.setOutput("changes", changes); + core.setOutput("llm-prompt", llmPrompt); + core.setOutput("changelog", changelog); +}; From dd1ff6a16c7db994bec948998c969aeafcd9a546 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:58:08 +0000 Subject: [PATCH 5/5] feat(release): switch llm integration to langchain multi-provider Co-authored-by: neilime <314088+neilime@users.noreply.github.com> --- actions/release/summarize-changelog/README.md | 10 ++-- .../release/summarize-changelog/action.yml | 31 +++++++++++-- .../release/summarize-changelog/summarize.js | 46 ++++++++++++------- 3 files changed, 63 insertions(+), 24 deletions(-) diff --git a/actions/release/summarize-changelog/README.md b/actions/release/summarize-changelog/README.md index e09cdc27..74469877 100644 --- a/actions/release/summarize-changelog/README.md +++ b/actions/release/summarize-changelog/README.md @@ -9,7 +9,7 @@ Features: - Supports conventional commit grouping. - Supports configurable Markdown templates. - Can inject a summary generated by any LLM provider (`llm-summary` input). -- Can generate summary directly through an OpenAI-compatible API using the `openai` npm package. +- Can generate summary through LangChain using multiple providers (`openai`, `anthropic`, `google-genai`). - Can generate the summary from the produced `llm-prompt` via a custom command (`llm-summary-command` input). - Exposes a provider-agnostic `llm-prompt` output to integrate with any LLM action. @@ -23,6 +23,7 @@ Features: head-ref: HEAD conventional-commits: "true" llm-summary: "" + llm-provider: "openai" llm-model: "gpt-4o-mini" llm-api-key: ${{ secrets.OPENAI_API_KEY }} llm-base-url: "https://api.openai.com/v1" @@ -44,9 +45,10 @@ Features: | `head-ref` | Head Git ref (included in the range). | true | - | | `conventional-commits` | Group commit messages by conventional commit type. | false | `true` | | `llm-summary` | Optional summary generated by any LLM provider. | false | `""` | -| `llm-model` | Optional OpenAI-compatible model used to generate summary from `llm-prompt`. | false | `""` | -| `llm-api-key` | Optional API key for the OpenAI-compatible model provider. | false | `""` | -| `llm-base-url` | Optional OpenAI-compatible API base URL. | false | `https://api.openai.com/v1` | +| `llm-provider` | LLM provider used with LangChain (`openai`, `anthropic`, `google-genai`). | false | `openai` | +| `llm-model` | Optional model used to generate summary from `llm-prompt`. | false | `""` | +| `llm-api-key` | Optional API key for the selected LLM provider. | false | `""` | +| `llm-base-url` | Optional base URL (used for `openai` provider). | false | `https://api.openai.com/v1` | | `llm-summary-command` | Optional command that reads `llm-prompt` on stdin and returns a summary on stdout. | false | `""` | | `markdown-template` | Markdown template with placeholders (`base_ref`, `changes`, …) | false | Built-in template | diff --git a/actions/release/summarize-changelog/action.yml b/actions/release/summarize-changelog/action.yml index 4cfa9603..736923b0 100644 --- a/actions/release/summarize-changelog/action.yml +++ b/actions/release/summarize-changelog/action.yml @@ -21,15 +21,19 @@ inputs: required: false default: "" llm-model: - description: "Optional OpenAI-compatible model used to generate summary from `llm-prompt`." + description: "Optional model used to generate summary from `llm-prompt`." required: false default: "" + llm-provider: + description: "LLM provider used with LangChain (`openai`, `anthropic`, `google-genai`)." + required: false + default: "openai" llm-api-key: - description: "Optional API key for the OpenAI-compatible model provider." + description: "Optional API key for the selected LLM provider." required: false default: "" llm-base-url: - description: "Optional OpenAI-compatible API base URL." + description: "Optional base URL (used for `openai` provider)." required: false default: "https://api.openai.com/v1" llm-summary-command: @@ -78,7 +82,25 @@ runs: - shell: bash if: ${{ !inputs.llm-summary && inputs.llm-model != '' }} - run: npm install --no-save openai@6.22.0 + env: + LLM_PROVIDER: ${{ inputs.llm-provider }} + run: | + provider="${LLM_PROVIDER:-openai}" + case "$provider" in + openai) + npm install --no-save langchain@1.2.25 @langchain/openai@1.2.8 + ;; + anthropic) + npm install --no-save langchain@1.2.25 @langchain/anthropic@1.3.18 + ;; + google-genai) + npm install --no-save langchain@1.2.25 @langchain/google-genai@2.1.19 + ;; + *) + echo "Unsupported llm-provider: $provider. Supported values: openai, anthropic, google-genai" + exit 1 + ;; + esac - id: summarize uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 @@ -88,6 +110,7 @@ runs: CONVENTIONAL_COMMITS: ${{ inputs.conventional-commits }} LLM_SUMMARY: ${{ inputs.llm-summary }} LLM_MODEL: ${{ inputs.llm-model }} + LLM_PROVIDER: ${{ inputs.llm-provider }} LLM_API_KEY: ${{ inputs.llm-api-key }} LLM_BASE_URL: ${{ inputs.llm-base-url }} LLM_SUMMARY_COMMAND: ${{ inputs.llm-summary-command }} diff --git a/actions/release/summarize-changelog/summarize.js b/actions/release/summarize-changelog/summarize.js index 752e1f0f..acc57b54 100644 --- a/actions/release/summarize-changelog/summarize.js +++ b/actions/release/summarize-changelog/summarize.js @@ -1,4 +1,5 @@ const { execSync } = require("node:child_process"); +const { initChatModel } = require("langchain/chat_models/universal"); module.exports = async ({ core }) => { const baseRef = process.env.BASE_REF.trim(); @@ -7,6 +8,7 @@ module.exports = async ({ core }) => { process.env.CONVENTIONAL_COMMITS.trim() === "true"; const llmSummaryInput = process.env.LLM_SUMMARY.trim(); const llmModel = process.env.LLM_MODEL.trim(); + const llmProvider = process.env.LLM_PROVIDER.trim() || "openai"; const llmApiKey = process.env.LLM_API_KEY.trim(); const llmBaseUrl = process.env.LLM_BASE_URL.trim(); const llmSummaryCommand = process.env.LLM_SUMMARY_COMMAND.trim(); @@ -88,24 +90,36 @@ module.exports = async ({ core }) => { return; } - const OpenAI = require("openai"); - const openai = new OpenAI({ - apiKey: llmApiKey, - baseURL: llmBaseUrl || undefined, - }); - - const completion = await openai.chat.completions.create({ + const llmConfig = { model: llmModel, - messages: [ - { - role: "system", - content: "You generate concise release summaries in Markdown.", - }, - { role: "user", content: llmPrompt }, - ], - }); + modelProvider: llmProvider, + }; + if (llmProvider === "openai") { + llmConfig.apiKey = llmApiKey; + if (llmBaseUrl) { + llmConfig.configuration = { baseURL: llmBaseUrl }; + } + } else if (llmProvider === "anthropic") { + llmConfig.apiKey = llmApiKey; + } else if (llmProvider === "google-genai") { + llmConfig.apiKey = llmApiKey; + } else { + core.setFailed( + "Unsupported llm-provider. Supported values: openai, anthropic, google-genai.", + ); + return; + } - llmSummary = completion.choices?.[0]?.message?.content?.trim() || ""; + const llm = await initChatModel(undefined, llmConfig); + const response = await llm.invoke([ + { + role: "system", + content: "You generate concise release summaries in Markdown.", + }, + { role: "user", content: llmPrompt }, + ]); + llmSummary = + typeof response?.content === "string" ? response.content.trim() : ""; } if (!llmSummary && llmSummaryCommand) {