From e97301eaaaa864a00b22aaaeafe82aa95b54c105 Mon Sep 17 00:00:00 2001 From: baldwinhou Date: Tue, 26 May 2026 22:57:16 +0800 Subject: [PATCH 1/2] ci: generate release notes with AI --- .github/release.yml | 29 ++ .github/workflows/desktop-release.yml | 16 +- .../test/backend/release-notes.test.mjs | 145 +++++++++ scripts/release/create-ai-release-notes.mjs | 278 ++++++++++++++++++ 4 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 .github/release.yml create mode 100644 crates/agent-gui/test/backend/release-notes.test.mjs create mode 100644 scripts/release/create-ai-release-notes.mjs diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..ab4e4688 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,29 @@ +changelog: + exclude: + labels: + - ignore-for-release + authors: + - dependabot + - github-actions + categories: + - title: Features + labels: + - feature + - enhancement + - title: Fixes + labels: + - fix + - bug + - title: Documentation + labels: + - documentation + - docs + - title: Maintenance + labels: + - chore + - ci + - refactor + - test + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 32ddf064..464bd152 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -334,6 +334,8 @@ jobs: LIVEAGENT_RELEASE_TAG: ${{ needs.release-metadata.outputs.release_tag }} steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 - uses: actions/download-artifact@v8 with: @@ -345,16 +347,26 @@ jobs: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} RELEASE_TAG: ${{ needs.release-metadata.outputs.release_tag }} + PACKYCODE_API_KEY: ${{ secrets.PACKYCODE_API_KEY }} + AI_RELEASE_NOTES_API_KEY: ${{ secrets.AI_RELEASE_NOTES_API_KEY }} + AI_RELEASE_NOTES_BASE_URL: ${{ vars.AI_RELEASE_NOTES_BASE_URL }} + AI_RELEASE_NOTES_MODEL: ${{ vars.AI_RELEASE_NOTES_MODEL }} run: | set -euo pipefail is_prerelease="$LIVEAGENT_IS_PRERELEASE" + github_notes_path="$RUNNER_TEMP/liveagent-github-release-notes.md" notes_path="$RUNNER_TEMP/liveagent-release-notes.md" if ! gh api "repos/$GH_REPO/releases/generate-notes" \ -f tag_name="$RELEASE_TAG" \ - --jq ".body" > "$notes_path"; then - printf 'LiveAgent %s\n' "$RELEASE_TAG" > "$notes_path" + --jq ".body" > "$github_notes_path"; then + printf 'LiveAgent %s\n' "$RELEASE_TAG" > "$github_notes_path" fi + if [ ! -s "$github_notes_path" ]; then + printf 'LiveAgent %s\n' "$RELEASE_TAG" > "$github_notes_path" + fi + + node scripts/release/create-ai-release-notes.mjs "$RELEASE_TAG" "$notes_path" "$github_notes_path" if [ ! -s "$notes_path" ]; then printf 'LiveAgent %s\n' "$RELEASE_TAG" > "$notes_path" fi diff --git a/crates/agent-gui/test/backend/release-notes.test.mjs b/crates/agent-gui/test/backend/release-notes.test.mjs new file mode 100644 index 00000000..0f9f5675 --- /dev/null +++ b/crates/agent-gui/test/backend/release-notes.test.mjs @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import http from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const guiRoot = path.resolve(fileURLToPath(new URL("../..", import.meta.url))); +const repoRoot = path.resolve(guiRoot, "../.."); +const notesScript = path.join(repoRoot, "scripts/release/create-ai-release-notes.mjs"); + +function runNotesScript(args, env = {}) { + return spawnSync(process.execPath, [notesScript, ...args], { + cwd: repoRoot, + encoding: "utf8", + env: { + ...process.env, + ...env, + }, + }); +} + +function runNotesScriptAsync(args, env = {}) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [notesScript, ...args], { + cwd: repoRoot, + env: { + ...process.env, + ...env, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("close", (status) => { + resolve({ status, stdout, stderr }); + }); + }); +} + +function listen(server) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve(server.address())); + }); +} + +function close(server) { + return new Promise((resolve, reject) => { + server.closeAllConnections?.(); + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +test("AI release notes script falls back when no API key is configured", () => { + const dir = mkdtempSync(path.join(tmpdir(), "liveagent-notes-")); + try { + const outputPath = path.join(dir, "notes.md"); + const fallbackPath = path.join(dir, "fallback.md"); + writeFileSync(fallbackPath, "## What's Changed\n\n- Fallback notes.\n"); + + const result = runNotesScript(["v0.1.6", outputPath, fallbackPath], { + AI_RELEASE_NOTES_API_KEY: "", + PACKYCODE_API_KEY: "", + OPENAI_API_KEY: "", + }); + + assert.equal( + result.status, + 0, + `notes script failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + assert.equal(readFileSync(outputPath, "utf8"), "## What's Changed\n\n- Fallback notes.\n"); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}); + +test("AI release notes script calls Responses API and writes markdown", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "liveagent-notes-")); + let requestBody = ""; + + const server = http.createServer((request, response) => { + assert.equal(request.method, "POST"); + assert.equal(request.url, "/v1/responses"); + assert.equal(request.headers.authorization, "Bearer test-key"); + + request.setEncoding("utf8"); + request.on("data", (chunk) => { + requestBody += chunk; + }); + request.on("end", () => { + response.setHeader("content-type", "application/json"); + response.setHeader("connection", "close"); + response.end( + JSON.stringify({ + output_text: + "# LiveAgent v0.1.6\n\n> Release notes generated from repository context.\n\n## Overview\n\nLiveAgent now publishes cleaner release notes.", + }), + ); + }); + }); + + try { + const address = await listen(server); + const outputPath = path.join(dir, "notes.md"); + const fallbackPath = path.join(dir, "fallback.md"); + writeFileSync(fallbackPath, "## What's Changed\n\n- GitHub fallback notes.\n"); + + const result = await runNotesScriptAsync(["v0.1.6", outputPath, fallbackPath], { + AI_RELEASE_NOTES_API_KEY: "test-key", + AI_RELEASE_NOTES_BASE_URL: `http://${address.address}:${address.port}/v1`, + AI_RELEASE_NOTES_MODEL: "gpt-test", + AI_RELEASE_NOTES_TIMEOUT_MS: "2000", + PACKYCODE_API_KEY: "", + OPENAI_API_KEY: "", + }); + + assert.equal( + result.status, + 0, + `notes script failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + + const requestJson = JSON.parse(requestBody); + assert.equal(requestJson.model, "gpt-test"); + assert.match(JSON.stringify(requestJson.input), /Release tag: v0\.1\.6/); + assert.match(readFileSync(outputPath, "utf8"), /^# LiveAgent v0\.1\.6/); + assert.match(readFileSync(outputPath, "utf8"), /cleaner release notes/); + } finally { + await close(server); + rmSync(dir, { force: true, recursive: true }); + } +}); diff --git a/scripts/release/create-ai-release-notes.mjs b/scripts/release/create-ai-release-notes.mjs new file mode 100644 index 00000000..a7ddbe77 --- /dev/null +++ b/scripts/release/create-ai-release-notes.mjs @@ -0,0 +1,278 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { copyFileSync, readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { parseReleaseVersion } from "./release-version.mjs"; + +const DEFAULT_BASE_URL = "https://codex-api.packycode.com/v1"; +const DEFAULT_MODEL = "gpt-5.5"; +const MAX_CONTEXT_CHARS = 22000; + +const [releaseTagArg, outputPath, fallbackNotesPath] = process.argv.slice(2); + +function usage() { + return "Usage: create-ai-release-notes.mjs [fallback-notes-file]"; +} + +function fail(message, code = 1) { + console.error(message); + process.exit(code); +} + +if (!releaseTagArg || !outputPath) { + fail(usage()); +} + +let releaseVersion; +try { + releaseVersion = parseReleaseVersion(releaseTagArg); +} catch (error) { + fail(error instanceof Error ? error.message : String(error)); +} + +function runGit(args, options = {}) { + const result = spawnSync("git", args, { + cwd: options.cwd ?? process.cwd(), + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + }); + if (result.status !== 0) { + if (options.optional) return ""; + throw new Error(`git ${args.join(" ")} failed: ${result.stderr.trim()}`); + } + return result.stdout.trim(); +} + +function compact(value, maxChars = MAX_CONTEXT_CHARS) { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}\n\n[truncated]`; +} + +function fallbackNotes() { + if (fallbackNotesPath) { + try { + const fallback = readFileSync(fallbackNotesPath, "utf8").trim(); + if (fallback) return fallback; + } catch { + // Fall through to a minimal note. + } + } + return `# LiveAgent ${releaseVersion.releaseTag}\n\nRelease ${releaseVersion.releaseTag}.`; +} + +function writeFallback(reason) { + console.warn(`AI release notes unavailable: ${reason}`); + if (fallbackNotesPath) { + try { + copyFileSync(fallbackNotesPath, outputPath); + console.log(`Wrote fallback release notes: ${outputPath}`); + return; + } catch { + // Fall through to generated fallback notes. + } + } + writeFileSync(outputPath, `${fallbackNotes()}\n`); + console.log(`Wrote fallback release notes: ${outputPath}`); +} + +function stripCodeFence(markdown) { + const trimmed = markdown.trim(); + const match = trimmed.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```$/i); + return match ? match[1].trim() : trimmed; +} + +function normalizeMarkdown(markdown) { + let output = stripCodeFence(markdown); + if (!output) return ""; + if (!output.startsWith("#")) { + output = `# LiveAgent ${releaseVersion.releaseTag}\n\n${output}`; + } + return `${output.trim()}\n`; +} + +function previousTagFor(releaseCommit) { + return runGit(["describe", "--tags", "--abbrev=0", `${releaseCommit}^`], { + optional: true, + }); +} + +function collectContext() { + const releaseCommit = runGit(["rev-list", "-n", "1", releaseVersion.releaseTag]); + const previousTag = previousTagFor(releaseCommit); + const range = previousTag ? `${previousTag}..${releaseCommit}` : releaseCommit; + const repository = process.env.GITHUB_REPOSITORY?.trim() || "Stack-Cairn/LiveAgent"; + + const commitLog = runGit([ + "log", + "--date=short", + "--format=%h%x09%ad%x09%an%x09%s", + range, + ]); + const diffStat = previousTag + ? runGit(["diff", "--stat", previousTag, releaseCommit], { optional: true }) + : runGit(["show", "--stat", "--oneline", "--no-renames", releaseCommit], { optional: true }); + const changedFiles = previousTag + ? runGit(["diff", "--name-status", previousTag, releaseCommit], { optional: true }) + : runGit(["show", "--name-status", "--format=", releaseCommit], { optional: true }); + const githubNotes = fallbackNotesPath + ? readFileSync(fallbackNotesPath, "utf8").trim() + : ""; + + return { + appVersion: releaseVersion.appVersion, + changedFiles: compact(changedFiles, 7000), + commitLog: compact(commitLog, 10000), + diffStat: compact(diffStat, 7000), + githubNotes: compact(githubNotes, 8000), + previousTag, + range, + releaseCommit, + releaseTag: releaseVersion.releaseTag, + repository, + }; +} + +function buildPrompt(context) { + return [ + `Repository: ${context.repository}`, + `Release tag: ${context.releaseTag}`, + `App version: ${context.appVersion}`, + `Previous tag: ${context.previousTag || "none"}`, + `Commit range: ${context.range}`, + "", + "Write polished GitHub release notes in Markdown for this release.", + "", + "Rules:", + "- Output Markdown only.", + "- Do not invent features, fixes, metrics, dates, warnings, contributors, or compatibility claims.", + "- Use only the provided GitHub notes, commit log, diff stat, and changed files.", + "- Write for end users first, developers second.", + "- Start with exactly this H1: # LiveAgent " + context.releaseTag, + "- Add a one-sentence blockquote summary after the H1.", + "- Use concise sections: Overview, Highlights, Added, Changed, Fixed, Internal.", + "- Omit a section if there is no evidence for it.", + "- Keep the release notes useful and skimmable, not a raw commit dump.", + "- Mention PR numbers and contributors only when present in the context.", + "", + "GitHub generated notes:", + "```markdown", + context.githubNotes || "(none)", + "```", + "", + "Commit log:", + "```text", + context.commitLog || "(none)", + "```", + "", + "Diff stat:", + "```text", + context.diffStat || "(none)", + "```", + "", + "Changed files:", + "```text", + context.changedFiles || "(none)", + "```", + ].join("\n"); +} + +function responseText(payload) { + if (typeof payload.output_text === "string") return payload.output_text; + + const output = payload.output; + if (Array.isArray(output)) { + const parts = []; + for (const item of output) { + if (!Array.isArray(item.content)) continue; + for (const content of item.content) { + if (typeof content.text === "string") parts.push(content.text); + } + } + if (parts.length > 0) return parts.join("\n"); + } + + const choice = payload.choices?.[0]?.message?.content; + if (typeof choice === "string") return choice; + if (Array.isArray(choice)) { + return choice + .map((part) => (typeof part.text === "string" ? part.text : "")) + .filter(Boolean) + .join("\n"); + } + + return ""; +} + +async function createResponse({ apiKey, baseUrl, model, prompt }) { + const endpoint = `${baseUrl.replace(/\/+$/, "")}/responses`; + const timeoutMs = Number.parseInt(process.env.AI_RELEASE_NOTES_TIMEOUT_MS ?? "60000", 10); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), Number.isFinite(timeoutMs) ? timeoutMs : 60000); + const response = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + signal: controller.signal, + body: JSON.stringify({ + input: [ + { + role: "system", + content: [ + { + type: "input_text", + text: "You are a precise release-notes editor. You never make claims that are not grounded in the provided repository context.", + }, + ], + }, + { + role: "user", + content: [{ type: "input_text", text: prompt }], + }, + ], + max_output_tokens: 3000, + model, + }), + }).finally(() => clearTimeout(timeout)); + + const text = await response.text(); + if (!response.ok) { + throw new Error(`Responses API returned HTTP ${response.status}: ${text.slice(0, 500)}`); + } + return JSON.parse(text); +} + +async function main() { + const apiKey = + process.env.AI_RELEASE_NOTES_API_KEY?.trim() || + process.env.PACKYCODE_API_KEY?.trim() || + process.env.OPENAI_API_KEY?.trim(); + if (!apiKey) { + writeFallback("missing AI_RELEASE_NOTES_API_KEY/PACKYCODE_API_KEY/OPENAI_API_KEY"); + return; + } + + const baseUrl = process.env.AI_RELEASE_NOTES_BASE_URL?.trim() || DEFAULT_BASE_URL; + const model = process.env.AI_RELEASE_NOTES_MODEL?.trim() || DEFAULT_MODEL; + + try { + const context = collectContext(); + const prompt = buildPrompt(context); + const payload = await createResponse({ apiKey, baseUrl, model, prompt }); + const markdown = normalizeMarkdown(responseText(payload)); + if (!markdown) { + writeFallback("model returned empty release notes"); + return; + } + writeFileSync(outputPath, markdown); + console.log(`Wrote AI release notes with ${model}: ${outputPath}`); + } catch (error) { + writeFallback(error instanceof Error ? error.message : String(error)); + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} From fa385cb734ef4a8444b45e2026ead2cecf8b067e Mon Sep 17 00:00:00 2001 From: baldwinhou Date: Tue, 26 May 2026 23:05:16 +0800 Subject: [PATCH 2/2] test: make AI release notes test self contained --- .../test/backend/release-notes.test.mjs | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/crates/agent-gui/test/backend/release-notes.test.mjs b/crates/agent-gui/test/backend/release-notes.test.mjs index 0f9f5675..e7d3ffdf 100644 --- a/crates/agent-gui/test/backend/release-notes.test.mjs +++ b/crates/agent-gui/test/backend/release-notes.test.mjs @@ -11,9 +11,9 @@ const guiRoot = path.resolve(fileURLToPath(new URL("../..", import.meta.url))); const repoRoot = path.resolve(guiRoot, "../.."); const notesScript = path.join(repoRoot, "scripts/release/create-ai-release-notes.mjs"); -function runNotesScript(args, env = {}) { +function runNotesScript(args, env = {}, options = {}) { return spawnSync(process.execPath, [notesScript, ...args], { - cwd: repoRoot, + cwd: options.cwd ?? repoRoot, encoding: "utf8", env: { ...process.env, @@ -22,10 +22,10 @@ function runNotesScript(args, env = {}) { }); } -function runNotesScriptAsync(args, env = {}) { +function runNotesScriptAsync(args, env = {}, options = {}) { return new Promise((resolve) => { const child = spawn(process.execPath, [notesScript, ...args], { - cwd: repoRoot, + cwd: options.cwd ?? repoRoot, env: { ...process.env, ...env, @@ -49,6 +49,32 @@ function runNotesScriptAsync(args, env = {}) { }); } +function runGit(args, cwd) { + const result = spawnSync("git", args, { + cwd, + encoding: "utf8", + }); + assert.equal( + result.status, + 0, + `git ${args.join(" ")} failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); +} + +function initTaggedRepo(dir, tag) { + runGit(["init"], dir); + runGit(["config", "user.name", "Release Test"], dir); + runGit(["config", "user.email", "release-test@example.com"], dir); + writeFileSync(path.join(dir, "README.md"), "# Release test\n"); + runGit(["add", "README.md"], dir); + runGit(["commit", "-m", "Initial release"], dir); + runGit(["tag", "v0.1.5"], dir); + writeFileSync(path.join(dir, "README.md"), "# Release test\n\nAI notes.\n"); + runGit(["add", "README.md"], dir); + runGit(["commit", "-m", "Improve release notes"], dir); + runGit(["tag", tag], dir); +} + function listen(server) { return new Promise((resolve, reject) => { server.once("error", reject); @@ -114,6 +140,7 @@ test("AI release notes script calls Responses API and writes markdown", async () try { const address = await listen(server); + initTaggedRepo(dir, "v0.1.6"); const outputPath = path.join(dir, "notes.md"); const fallbackPath = path.join(dir, "fallback.md"); writeFileSync(fallbackPath, "## What's Changed\n\n- GitHub fallback notes.\n"); @@ -125,7 +152,7 @@ test("AI release notes script calls Responses API and writes markdown", async () AI_RELEASE_NOTES_TIMEOUT_MS: "2000", PACKYCODE_API_KEY: "", OPENAI_API_KEY: "", - }); + }, { cwd: dir }); assert.equal( result.status,