From 5a89d65018c55f6cb07c363200a9d30af714a314 Mon Sep 17 00:00:00 2001 From: Jonathan Segev Date: Thu, 11 Jun 2026 15:30:05 -0400 Subject: [PATCH 01/13] feat(changelog): map release tags to sdk/language/version --- changelog-release-pr/scripts/tag-meta.cjs | 50 +++++++++++++++++++ .../scripts/tag-meta.test.cjs | 49 ++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 changelog-release-pr/scripts/tag-meta.cjs create mode 100644 changelog-release-pr/scripts/tag-meta.test.cjs diff --git a/changelog-release-pr/scripts/tag-meta.cjs b/changelog-release-pr/scripts/tag-meta.cjs new file mode 100644 index 0000000..7cc964a --- /dev/null +++ b/changelog-release-pr/scripts/tag-meta.cjs @@ -0,0 +1,50 @@ +// Map a repo + release tag to changelog metadata, and build package-registry URLs. +// Pure, dependency-free. Mirrors site/src/config/changelog.ts (Plan 1 contract). + +function cleanVersion(raw) { + // Strip a leading 'v' and any stray dot after it (handles 'v.1.2.0'). + return raw.replace(/^v\.?/, '') +} + +/** + * @param {string} repo e.g. 'strands-agents/harness-sdk' | 'strands-agents/evals' + * @param {string} tag the release tag + * @returns {{sdk:'harness'|'evals', language:'python'|'typescript'|undefined, version:string}|null} + */ +function tagToMeta(repo, tag) { + const isEvals = repo.endsWith('/evals') + if (isEvals) { + // Evals is python-only; accept bare vX or python/vX. + const m = tag.match(/(?:^|\/)v\.?(\d.*)$/) + if (!m) return null + return { sdk: 'evals', language: undefined, version: cleanVersion('v' + m[1]) } + } + // harness-sdk + if (tag.startsWith('python-wasm/')) return null + if (tag.startsWith('python/')) { + return { sdk: 'harness', language: 'python', version: cleanVersion(tag.slice('python/'.length)) } + } + if (tag.startsWith('typescript/')) { + return { sdk: 'harness', language: 'typescript', version: cleanVersion(tag.slice('typescript/'.length)) } + } + if (/^v\.?\d/.test(tag)) { + return { sdk: 'harness', language: 'python', version: cleanVersion(tag) } + } + return null +} + +const pypi = (name, v) => `https://pypi.org/project/${name}/${v}/` +const npm = (name, v) => `https://www.npmjs.com/package/${name}/v/${v}` + +/** + * @param {'harness'|'evals'} sdk + * @param {'python'|'typescript'|undefined} language + * @param {string} version + */ +function getPackageUrl(sdk, language, version) { + if (sdk === 'evals') return pypi('strands-agents-evals', version) + if (language === 'typescript') return npm('@strands-agents/sdk', version) + return pypi('strands-agents', version) +} + +module.exports = { tagToMeta, getPackageUrl } diff --git a/changelog-release-pr/scripts/tag-meta.test.cjs b/changelog-release-pr/scripts/tag-meta.test.cjs new file mode 100644 index 0000000..bf41d0f --- /dev/null +++ b/changelog-release-pr/scripts/tag-meta.test.cjs @@ -0,0 +1,49 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const { tagToMeta, getPackageUrl } = require('./tag-meta.cjs') + +test('harness python prefixed tag', () => { + assert.deepEqual(tagToMeta('strands-agents/harness-sdk', 'python/v1.42.0'), { + sdk: 'harness', language: 'python', version: '1.42.0', + }) +}) + +test('harness typescript prefixed tag', () => { + assert.deepEqual(tagToMeta('strands-agents/harness-sdk', 'typescript/v1.4.0'), { + sdk: 'harness', language: 'typescript', version: '1.4.0', + }) +}) + +test('harness bare v tag is pre-monorepo python', () => { + assert.deepEqual(tagToMeta('strands-agents/harness-sdk', 'v1.9.1'), { + sdk: 'harness', language: 'python', version: '1.9.1', + }) +}) + +test('evals bare v tag is python-only (no language)', () => { + assert.deepEqual(tagToMeta('strands-agents/evals', 'v0.2.1'), { + sdk: 'evals', language: undefined, version: '0.2.1', + }) +}) + +test('evals python-prefixed tag also maps to evals python', () => { + assert.deepEqual(tagToMeta('strands-agents/evals', 'python/v0.1.3'), { + sdk: 'evals', language: undefined, version: '0.1.3', + }) +}) + +test('malformed typescript tag still parses', () => { + assert.deepEqual(tagToMeta('strands-agents/harness-sdk', 'typescript/v.1.2.0'), { + sdk: 'harness', language: 'typescript', version: '1.2.0', + }) +}) + +test('python-wasm is skipped (null)', () => { + assert.equal(tagToMeta('strands-agents/harness-sdk', 'python-wasm/v0.0.1'), null) +}) + +test('package urls', () => { + assert.equal(getPackageUrl('harness', 'python', '1.42.0'), 'https://pypi.org/project/strands-agents/1.42.0/') + assert.equal(getPackageUrl('harness', 'typescript', '1.4.0'), 'https://www.npmjs.com/package/@strands-agents/sdk/v/1.4.0') + assert.equal(getPackageUrl('evals', undefined, '0.2.1'), 'https://pypi.org/project/strands-agents-evals/0.2.1/') +}) From 92c181209689096f74e71878f71da400433cbbdc Mon Sep 17 00:00:00 2001 From: Jonathan Segev Date: Thu, 11 Jun 2026 15:31:37 -0400 Subject: [PATCH 02/13] feat(changelog): parse release body into structured lines with drift guard --- .../scripts/parse-release-body.cjs | 63 +++++++++++++++++++ .../scripts/parse-release-body.test.cjs | 61 ++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 changelog-release-pr/scripts/parse-release-body.cjs create mode 100644 changelog-release-pr/scripts/parse-release-body.test.cjs diff --git a/changelog-release-pr/scripts/parse-release-body.cjs b/changelog-release-pr/scripts/parse-release-body.cjs new file mode 100644 index 0000000..13f9857 --- /dev/null +++ b/changelog-release-pr/scripts/parse-release-body.cjs @@ -0,0 +1,63 @@ +// Parse GitHub's auto-generated "What's Changed" release body into structured +// lines. Pure, dependency-free. +// +// Line shape: "* ()!: by @<author> in <pr-url>" +// (scope, '!' marker, and "by @author" are all optional). + +const LINE = /^\s*[-*]\s+(.*)$/ +// "<msg> by @<author> in <pr-url>" OR "<msg> in <pr-url>" +const TAIL = /^(.*?)(?:\s+by\s+@([\w-]+))?\s+in\s+https?:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)\s*$/i +const CONVENTIONAL = /^(feat|fix|docs|style|refactor|perf|test|chore|build|ci|revert)(?:\(([^)]+)\))?(!)?:\s*(.+)$/i +const KNOWN_TYPES = new Set(['feat', 'fix', 'docs', 'perf', 'refactor', 'test', 'chore']) + +/** + * @param {string|null|undefined} body + * @returns {Array<{type:string,scope:string|null,breaking:boolean,title:string,author:string|null,pr:number|null,prRepo:string|null}>} + */ +function parseReleaseBody(body) { + if (!body) return [] + const out = [] + for (const raw of body.split('\n')) { + const li = raw.match(LINE) + if (!li) continue + const tail = li[1].trim().match(TAIL) + if (!tail) continue // not an itemized PR line (skips "omitted" notes, footers) + const message = tail[1].trim() + const author = tail[2] || null + const prRepo = tail[3] || null + const pr = tail[4] ? Number(tail[4]) : null + + const cc = message.match(CONVENTIONAL) + if (cc) { + const type = cc[1].toLowerCase() + out.push({ + type: KNOWN_TYPES.has(type) ? type : 'other', + scope: cc[2] || null, + breaking: cc[3] === '!', + title: cc[4].trim(), + author, pr, prRepo, + }) + } else { + out.push({ type: 'other', scope: null, breaking: false, title: message, author, pr, prRepo }) + } + } + return out +} + +// Loosely count bullets that look like changelog entries, using a format- +// INDEPENDENT heuristic (a list bullet mentioning an author '@' or a PR +// '#'/'/pull/'). Deliberately looser than parseReleaseBody's strict TAIL: if +// this is high but parseReleaseBody returns far fewer, the release-note format +// has likely drifted (or notes are hand-written) and callers should flag it. +const LOOSE_ENTRY = /(@[\w-]+|#\d+|\/pull\/\d+)/ +function countChangelogBullets(body) { + if (!body) return 0 + let n = 0 + for (const raw of body.split('\n')) { + const li = raw.match(LINE) + if (li && LOOSE_ENTRY.test(li[1])) n++ + } + return n +} + +module.exports = { parseReleaseBody, countChangelogBullets } diff --git a/changelog-release-pr/scripts/parse-release-body.test.cjs b/changelog-release-pr/scripts/parse-release-body.test.cjs new file mode 100644 index 0000000..da7f4f5 --- /dev/null +++ b/changelog-release-pr/scripts/parse-release-body.test.cjs @@ -0,0 +1,61 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const { parseReleaseBody, countChangelogBullets } = require('./parse-release-body.cjs') + +const body = `## What's Changed + +* fix(tests): fix flaky tests by @lizradway in https://github.com/strands-agents/sdk-python/pull/2319 +* feat(gemini): plumb through cache tokens by @yatszhash in https://github.com/strands-agents/sdk-python/pull/2287 +* feat!: drop legacy run() by @pgrayy in https://github.com/strands-agents/sdk-python/pull/2299 +* docs: tidy readme by @someone in https://github.com/strands-agents/sdk-python/pull/2300 +* chore: bump deps in https://github.com/strands-agents/sdk-python/pull/2301 + +**Full Changelog**: https://github.com/strands-agents/sdk-python/compare/python/v1.41.0...python/v1.42.0` + +test('parses conventional-commit lines', () => { + const lines = parseReleaseBody(body) + assert.equal(lines.length, 5) + assert.deepEqual(lines[0], { + type: 'fix', scope: 'tests', breaking: false, + title: 'fix flaky tests', author: 'lizradway', + pr: 2319, prRepo: 'strands-agents/sdk-python', + }) + assert.equal(lines[1].type, 'feat') + assert.equal(lines[1].scope, 'gemini') + assert.equal(lines[2].breaking, true) // '!' marker + assert.equal(lines[2].type, 'feat') + assert.equal(lines[4].author, null) // line without "by @author" + assert.equal(lines[4].pr, 2301) +}) + +test('omitted-list body yields no entries', () => { + assert.deepEqual( + parseReleaseBody("## What's Changed\n\n*auto-generated itemized list omitted due to mono-repository merge*"), + [] + ) +}) + +test('empty/undefined body yields no entries', () => { + assert.deepEqual(parseReleaseBody(''), []) + assert.deepEqual(parseReleaseBody(null), []) +}) + +test('non-conventional line falls back to type other', () => { + const lines = parseReleaseBody('* just did a thing by @x in https://github.com/o/r/pull/9') + assert.equal(lines[0].type, 'other') + assert.equal(lines[0].scope, null) + assert.equal(lines[0].title, 'just did a thing') +}) + +test('countChangelogBullets counts entry-like bullets, ignores notes', () => { + assert.equal(countChangelogBullets(body), 5) + assert.equal(countChangelogBullets('* auto-generated itemized list omitted'), 0) + assert.equal(countChangelogBullets(''), 0) +}) + +test('countChangelogBullets stays loose vs strict parser (drift signal)', () => { + // PR refs as #123 instead of full URL: loose counter sees them, strict parser does not. + const drifted = '* updated thing #11\n* fixed thing #12\n* added thing #13' + assert.equal(countChangelogBullets(drifted), 3) + assert.equal(parseReleaseBody(drifted).length, 0) +}) From 8f1384d964af48e1779d28150bb21af4b9ef0910 Mon Sep 17 00:00:00 2001 From: Jonathan Segev <jsegev01@gmail.com> Date: Thu, 11 Jun 2026 15:32:35 -0400 Subject: [PATCH 03/13] feat(changelog): enrich entries from linked PR labels and commit --- changelog-release-pr/scripts/enrich.cjs | 29 +++++++++++++++ changelog-release-pr/scripts/enrich.test.cjs | 39 ++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 changelog-release-pr/scripts/enrich.cjs create mode 100644 changelog-release-pr/scripts/enrich.test.cjs diff --git a/changelog-release-pr/scripts/enrich.cjs b/changelog-release-pr/scripts/enrich.cjs new file mode 100644 index 0000000..ccf3fa2 --- /dev/null +++ b/changelog-release-pr/scripts/enrich.cjs @@ -0,0 +1,29 @@ +// Enrich a parsed line from its linked PR: area-* labels, breaking flag, +// short merge-commit SHA, author. Pure given an injected fetcher (no network +// here), so it's unit-testable. Degrades to empty enrichment when the PR can't +// be fetched (deleted PR, missing permissions, old repo) — callers still emit +// the entry, just without areas/commit. + +/** + * @typedef {{labels:string[], merge_commit_sha:string|null, user:string|null}} PrData + * @typedef {(repo:string, num:number)=>Promise<PrData|null>} PrFetcher + */ + +/** + * @param {string} repo + * @param {number} num + * @param {PrFetcher} fetcher + * @returns {Promise<{areas:string[], breaking:boolean, commit:string|null, author:string|null}>} + */ +async function enrichFromPr(repo, num, fetcher) { + const pr = await fetcher(repo, num) + if (!pr) return { areas: [], breaking: false, commit: null, author: null } + const areas = (pr.labels || []) + .filter((l) => l.startsWith('area-')) + .map((l) => l.slice('area-'.length)) + const breaking = (pr.labels || []).some((l) => l.toLowerCase() === 'breaking change') + const commit = pr.merge_commit_sha ? pr.merge_commit_sha.slice(0, 7) : null + return { areas, breaking, commit, author: pr.user || null } +} + +module.exports = { enrichFromPr } diff --git a/changelog-release-pr/scripts/enrich.test.cjs b/changelog-release-pr/scripts/enrich.test.cjs new file mode 100644 index 0000000..10db4ad --- /dev/null +++ b/changelog-release-pr/scripts/enrich.test.cjs @@ -0,0 +1,39 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const { enrichFromPr } = require('./enrich.cjs') + +test('extracts areas, commit, author', async () => { + const fetcher = async (repo, num) => { + assert.equal(repo, 'strands-agents/harness-sdk') + assert.equal(num, 2287) + return { + labels: ['enhancement', 'python', 'area-model', 'area-otel', 'size/xs'], + merge_commit_sha: '155239dca769c8eea7652b2496822ea47283a1a9', + user: 'yatszhash', + } + } + const e = await enrichFromPr('strands-agents/harness-sdk', 2287, fetcher) + assert.deepEqual(e.areas, ['model', 'otel']) + assert.equal(e.breaking, false) + assert.equal(e.commit, '155239d') + assert.equal(e.author, 'yatszhash') +}) + +test('detects breaking label', async () => { + const f = async () => ({ labels: ['breaking change'], merge_commit_sha: 'abcdef0123', user: 'x' }) + const e = await enrichFromPr('r', 1, f) + assert.equal(e.breaking, true) +}) + +test('missing pr degrades gracefully (fetcher returns null)', async () => { + const f = async () => null + const e = await enrichFromPr('r', 1, f) + assert.deepEqual(e, { areas: [], breaking: false, commit: null, author: null }) +}) + +test('no merge sha yields null commit', async () => { + const f = async () => ({ labels: [], merge_commit_sha: null, user: null }) + const e = await enrichFromPr('r', 1, f) + assert.equal(e.commit, null) + assert.equal(e.author, null) +}) From 51a1d13798f08867f73e03dd3096e0310f49f281 Mon Sep 17 00:00:00 2001 From: Jonathan Segev <jsegev01@gmail.com> Date: Thu, 11 Jun 2026 15:33:34 -0400 Subject: [PATCH 04/13] feat(changelog): render release markdown preserving curated highlights --- .../scripts/render-markdown.cjs | 80 +++++++++++++++++ .../scripts/render-markdown.test.cjs | 89 +++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 changelog-release-pr/scripts/render-markdown.cjs create mode 100644 changelog-release-pr/scripts/render-markdown.test.cjs diff --git a/changelog-release-pr/scripts/render-markdown.cjs b/changelog-release-pr/scripts/render-markdown.cjs new file mode 100644 index 0000000..2646a78 --- /dev/null +++ b/changelog-release-pr/scripts/render-markdown.cjs @@ -0,0 +1,80 @@ +// Render a release into the changelog markdown file format consumed by the +// harness-sdk content collection (Plan 1 Zod schema). Hand-rolled minimal YAML +// emitter — entries are flat objects emitted as inline flow maps, mirroring the +// committed fixtures. mergePreserving keeps any human-written highlights/body +// on re-sync while refreshing the parsed entries/urls. + +function q(s) { + return `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` +} + +// Bareword if safe for YAML, else quoted. Anything starting with a digit or +// containing YAML-significant chars gets quoted. +function scalar(v) { + if (v === null || v === undefined) return 'null' + if (typeof v === 'number') return String(v) + if (v === '') return '""' + if (/^[\w.@/-]+$/.test(v) && !/^\d/.test(v)) return v + return q(v) +} + +function flowEntry(e) { + const parts = [ + `type: ${e.type}`, + `breaking: ${e.breaking === true}`, + `scope: ${e.scope ? scalar(e.scope) : 'null'}`, + `areas: [${e.areas.join(', ')}]`, + `title: ${q(e.title)}`, + `pr: ${e.pr == null ? 'null' : e.pr}`, + `prUrl: ${e.prUrl ? q(e.prUrl) : 'null'}`, + `commit: ${e.commit ? q(e.commit) : 'null'}`, + `commitUrl: ${e.commitUrl ? q(e.commitUrl) : 'null'}`, + `author: ${e.author ? scalar(e.author) : 'null'}`, + ] + return ` - { ${parts.join(', ')} }` +} + +/** + * @param {object} f release file shape (see build-release-file.cjs) + * @param {string} [body] optional curated markdown body to append + */ +function renderMarkdown(f, body = '') { + const lines = ['---'] + lines.push(`sdk: ${f.sdk}`) + if (f.language) lines.push(`language: ${f.language}`) + lines.push(`version: ${q(f.version)}`) + lines.push(`tag: ${f.tag}`) + lines.push(`date: ${f.date}`) + lines.push(`releaseUrl: ${f.releaseUrl}`) + lines.push(`packageUrl: ${f.packageUrl}`) + if (f.compareUrl) lines.push(`compareUrl: ${f.compareUrl}`) + if (f.highlights && f.highlights.trim()) { + lines.push('highlights: |') + for (const l of f.highlights.replace(/\s+$/, '').split('\n')) lines.push(` ${l}`) + } + if (f.entries && f.entries.length) { + lines.push('entries:') + for (const e of f.entries) lines.push(flowEntry(e)) + } else { + lines.push('entries: []') + } + lines.push('---') + return lines.join('\n') + '\n' + (body ? '\n' + body.replace(/\s+$/, '') + '\n' : '') +} + +// Pull the human-authored highlights block + markdown body out of an existing +// file so a re-sync doesn't clobber curation. Entries/urls always regenerate. +function mergePreserving(fresh, existing) { + if (!existing) return renderMarkdown(fresh) + const fm = existing.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/) + const body = fm ? fm[2].trim() : '' + let highlights = fresh.highlights + if (fm) { + // Capture a block scalar `highlights: |` up to the next top-level key or EOF. + const hl = fm[1].match(/highlights:\s*\|\s*\n([\s\S]*?)(?=\n[A-Za-z][\w-]*:|$)/) + if (hl) highlights = hl[1].replace(/^ {1,2}/gm, '').replace(/\s+$/, '') + } + return renderMarkdown({ ...fresh, highlights }, body) +} + +module.exports = { renderMarkdown, mergePreserving } diff --git a/changelog-release-pr/scripts/render-markdown.test.cjs b/changelog-release-pr/scripts/render-markdown.test.cjs new file mode 100644 index 0000000..45c4c4e --- /dev/null +++ b/changelog-release-pr/scripts/render-markdown.test.cjs @@ -0,0 +1,89 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const { renderMarkdown, mergePreserving } = require('./render-markdown.cjs') + +const file = { + sdk: 'harness', language: 'python', version: '1.42.0', tag: 'python/v1.42.0', + date: '2026-06-01', + releaseUrl: 'https://github.com/strands-agents/harness-sdk/releases/tag/python%2Fv1.42.0', + packageUrl: 'https://pypi.org/project/strands-agents/1.42.0/', + entries: [ + { + type: 'feat', breaking: false, scope: 'model', areas: ['model'], title: 'plumb cache tokens', + pr: 2287, prUrl: 'https://github.com/strands-agents/sdk-python/pull/2287', + commit: '155239d', commitUrl: 'https://github.com/strands-agents/sdk-python/commit/155239d', author: 'yatszhash', + }, + ], +} + +test('renders valid frontmatter markdown', () => { + const md = renderMarkdown(file) + assert.match(md, /^---\n/) + assert.match(md, /\nsdk: harness\n/) + assert.match(md, /\nlanguage: python\n/) + assert.match(md, /\nversion: "1\.42\.0"\n/) + assert.match(md, /\ntag: python\/v1\.42\.0\n/) + assert.match(md, /type: feat/) + assert.match(md, /areas: \[model\]/) + assert.match(md, /title: "plumb cache tokens"/) + assert.doesNotMatch(md, /highlights:/) // none provided +}) + +test('evals omits language key', () => { + const md = renderMarkdown({ + ...file, sdk: 'evals', language: undefined, tag: 'v0.2.1', version: '0.2.1', + releaseUrl: 'https://github.com/strands-agents/evals/releases/tag/v0.2.1', + packageUrl: 'https://pypi.org/project/strands-agents-evals/0.2.1/', + }) + assert.doesNotMatch(md, /\nlanguage:/) +}) + +test('empty entries renders entries: []', () => { + assert.match(renderMarkdown({ ...file, entries: [] }), /\nentries: \[\]\n/) +}) + +test('quotes all-digit commit so YAML keeps it a string', () => { + const md = renderMarkdown({ + ...file, + entries: [{ ...file.entries[0], commit: '1122334', commitUrl: 'https://github.com/o/r/commit/1122334' }], + }) + assert.match(md, /commit: "1122334"/) +}) + +test('null pr/commit/author render as null', () => { + const md = renderMarkdown({ + ...file, + entries: [{ type: 'feat', breaking: false, scope: null, areas: [], title: 'x', pr: null, prUrl: null, commit: null, commitUrl: null, author: null }], + }) + assert.match(md, /pr: null/) + assert.match(md, /commit: null/) + assert.match(md, /author: null/) + assert.match(md, /scope: null/) +}) + +test('mergePreserving keeps existing highlights + body, refreshes entries', () => { + const existing = `--- +sdk: harness +language: python +version: "1.42.0" +tag: python/v1.42.0 +date: 2026-06-01 +releaseUrl: https://example/r +packageUrl: https://example/p +highlights: | + Hand written summary. +entries: [] +--- + +Some curated prose body.` + const merged = mergePreserving(file, existing) + assert.match(merged, /highlights: \|/) + assert.match(merged, /Hand written summary\./) + assert.match(merged, /Some curated prose body\./) + assert.match(merged, /plumb cache tokens/) // entries refreshed from fresh file +}) + +test('mergePreserving with no existing file just renders fresh', () => { + const merged = mergePreserving(file, null) + assert.equal(merged, renderMarkdown(file)) +}) From 6990dec26aec7be6e1ad3475743b87616769ef0e Mon Sep 17 00:00:00 2001 From: Jonathan Segev <jsegev01@gmail.com> Date: Thu, 11 Jun 2026 15:34:29 -0400 Subject: [PATCH 05/13] feat(changelog): orchestrate a release into a rendered changelog file --- .../scripts/build-release-file.cjs | 72 +++++++++++++++++++ .../scripts/build-release-file.test.cjs | 65 +++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 changelog-release-pr/scripts/build-release-file.cjs create mode 100644 changelog-release-pr/scripts/build-release-file.test.cjs diff --git a/changelog-release-pr/scripts/build-release-file.cjs b/changelog-release-pr/scripts/build-release-file.cjs new file mode 100644 index 0000000..cb543d2 --- /dev/null +++ b/changelog-release-pr/scripts/build-release-file.cjs @@ -0,0 +1,72 @@ +// Orchestrate one GitHub release into a rendered changelog file. Pure given +// injected deps (enrich + readExisting), so it's unit-testable without network. + +const { tagToMeta, getPackageUrl } = require('./tag-meta.cjs') +const { parseReleaseBody, countChangelogBullets } = require('./parse-release-body.cjs') +const { renderMarkdown, mergePreserving } = require('./render-markdown.cjs') + +function fileNameFor(sdk, language, version) { + if (sdk === 'evals') return `evals/v${version}.md` + return `harness/${language}-v${version}.md` +} + +/** + * @param {string} repo the SOURCE repo the release belongs to + * @param {{tag_name:string, published_at:string, html_url:string, body:string|null}} release + * @param {{enrich:(prRepo:string,pr:number)=>Promise<{areas:string[],breaking:boolean,commit:string|null,author:string|null}>, readExisting:(path:string)=>Promise<string|null>}} deps + * @returns {Promise<{path:string, contents:string, warning?:string}|null>} + */ +async function buildReleaseFile(repo, release, deps) { + const meta = tagToMeta(repo, release.tag_name) + if (!meta) return null + + const parsed = parseReleaseBody(release.body) + + // Format-drift guard: if the body clearly has changelog bullets but few/none + // parsed, the format likely changed (or notes are hand-written). Don't fail — + // emit the file (still links the release) and attach a warning for the PR. + const bullets = countChangelogBullets(release.body) + const warning = + bullets >= 3 && parsed.length < bullets * 0.8 + ? `${release.tag_name}: parsed ${parsed.length} of ${bullets} changelog bullets — release-note format may have changed; review before merge.` + : undefined + + const entries = [] + for (const p of parsed) { + const prRepo = p.prRepo || repo + const enr = p.pr + ? await deps.enrich(prRepo, p.pr) + : { areas: [], breaking: false, commit: null, author: null } + const breaking = p.breaking || enr.breaking + entries.push({ + type: breaking && p.type === 'other' ? 'breaking' : p.type, + breaking, + scope: p.scope, + areas: enr.areas, + title: p.title, + pr: p.pr, + prUrl: p.pr ? `https://github.com/${prRepo}/pull/${p.pr}` : null, + commit: enr.commit, + commitUrl: enr.commit ? `https://github.com/${prRepo}/commit/${enr.commit}` : null, + author: enr.author || p.author, + }) + } + + const file = { + sdk: meta.sdk, + language: meta.language, + version: meta.version, + tag: release.tag_name, + date: release.published_at.slice(0, 10), + releaseUrl: release.html_url, + packageUrl: getPackageUrl(meta.sdk, meta.language, meta.version), + entries, + } + + const path = `site/src/content/changelog/${fileNameFor(meta.sdk, meta.language, meta.version)}` + const existing = await deps.readExisting(path) + const contents = existing ? mergePreserving(file, existing) : renderMarkdown(file) + return warning ? { path, contents, warning } : { path, contents } +} + +module.exports = { buildReleaseFile } diff --git a/changelog-release-pr/scripts/build-release-file.test.cjs b/changelog-release-pr/scripts/build-release-file.test.cjs new file mode 100644 index 0000000..09e4468 --- /dev/null +++ b/changelog-release-pr/scripts/build-release-file.test.cjs @@ -0,0 +1,65 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const { buildReleaseFile } = require('./build-release-file.cjs') + +const release = { + tag_name: 'python/v1.42.0', + published_at: '2026-06-01T18:18:57Z', + html_url: 'https://github.com/strands-agents/harness-sdk/releases/tag/python%2Fv1.42.0', + body: "## What's Changed\n* feat(model): plumb cache tokens by @yatszhash in https://github.com/strands-agents/sdk-python/pull/2287\n", +} + +test('produces correct path + parsed/enriched contents', async () => { + const result = await buildReleaseFile('strands-agents/harness-sdk', release, { + enrich: async () => ({ areas: ['model'], breaking: false, commit: '155239d', author: 'yatszhash' }), + readExisting: async () => null, + }) + assert.equal(result.path, 'site/src/content/changelog/harness/python-v1.42.0.md') + assert.match(result.contents, /sdk: harness/) + assert.match(result.contents, /title: "plumb cache tokens"/) + assert.match(result.contents, /areas: \[model\]/) + // prUrl/commitUrl use the PR's own repo (sdk-python), not harness-sdk + assert.match(result.contents, /sdk-python\/pull\/2287/) + assert.match(result.contents, /sdk-python\/commit\/155239d/) + assert.equal(result.warning, undefined) +}) + +test('evals file path + no language', async () => { + const evalsRelease = { + tag_name: 'v0.2.1', published_at: '2026-05-29T00:00:00Z', + html_url: 'https://github.com/strands-agents/evals/releases/tag/v0.2.1', + body: '* feat: add chaos testing by @x in https://github.com/strands-agents/evals/pull/224\n', + } + const r = await buildReleaseFile('strands-agents/evals', evalsRelease, { + enrich: async () => ({ areas: [], breaking: false, commit: 'aaa1111', author: 'x' }), + readExisting: async () => null, + }) + assert.equal(r.path, 'site/src/content/changelog/evals/v0.2.1.md') + assert.doesNotMatch(r.contents, /\nlanguage:/) +}) + +test('skips out-of-scope tags', async () => { + const r = await buildReleaseFile('strands-agents/harness-sdk', + { ...release, tag_name: 'python-wasm/v0.0.1' }, + { enrich: async () => ({ areas: [], breaking: false, commit: null, author: null }), readExisting: async () => null }) + assert.equal(r, null) +}) + +test('flags format-drift warning when bullets parse poorly', async () => { + const drifted = { ...release, body: "## What's Changed\n* updated thing #11\n* fixed thing #12\n* added thing #13\n" } + const r = await buildReleaseFile('strands-agents/harness-sdk', drifted, { + enrich: async () => ({ areas: [], breaking: false, commit: null, author: null }), + readExisting: async () => null, + }) + assert.ok(r) + assert.match(r.warning, /parsed 0 of 3/) +}) + +test('breaking marker promotes type when no conventional type', async () => { + // a non-conventional line that the PR labels mark breaking → type becomes 'breaking' + const r = await buildReleaseFile('strands-agents/harness-sdk', + { ...release, body: '* drop the old api by @x in https://github.com/strands-agents/harness-sdk/pull/1\n' }, + { enrich: async () => ({ areas: [], breaking: true, commit: 'bbb2222', author: 'x' }), readExisting: async () => null }) + assert.match(r.contents, /type: breaking/) + assert.match(r.contents, /breaking: true/) +}) From df575fa10a729a45c0f9e1ba6d96ebd2b451368f Mon Sep 17 00:00:00 2001 From: Jonathan Segev <jsegev01@gmail.com> Date: Thu, 11 Jun 2026 15:35:32 -0400 Subject: [PATCH 06/13] feat(changelog): select releases and write changelog files --- changelog-release-pr/scripts/run.cjs | 46 +++++++++++++++++ changelog-release-pr/scripts/run.test.cjs | 60 +++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 changelog-release-pr/scripts/run.cjs create mode 100644 changelog-release-pr/scripts/run.test.cjs diff --git a/changelog-release-pr/scripts/run.cjs b/changelog-release-pr/scripts/run.cjs new file mode 100644 index 0000000..733c02a --- /dev/null +++ b/changelog-release-pr/scripts/run.cjs @@ -0,0 +1,46 @@ +// Entry-point logic: pick releases (single tag or full backfill), build each +// into a file, write it, and collect any format-drift warnings. Pure given an +// injected client + fs ops, so it's unit-testable. The github-script wrapper +// (run-action.cjs) supplies a real client built from Octokit + node:fs. + +const { buildReleaseFile } = require('./build-release-file.cjs') +const { enrichFromPr } = require('./enrich.cjs') + +/** + * @param {{ + * repo:string, + * mode:'single'|'backfill', + * tag?:string, + * client:{ listReleases:(repo:string)=>Promise<any[]>, getRelease:(repo:string,tag:string)=>Promise<any|null>, getPr:(repo:string,num:number)=>Promise<any|null> }, + * readExisting:(path:string)=>Promise<string|null>, + * writeFile:(path:string,contents:string)=>Promise<void>, + * }} opts + * @returns {Promise<{written:string[], warnings:string[]}>} + */ +async function run(opts) { + let releases + if (opts.mode === 'backfill') { + releases = await opts.client.listReleases(opts.repo) + } else { + const r = await opts.client.getRelease(opts.repo, opts.tag) + releases = r ? [r] : [] + } + + const deps = { + enrich: (prRepo, pr) => enrichFromPr(prRepo, pr, opts.client.getPr), + readExisting: opts.readExisting, + } + + const written = [] + const warnings = [] + for (const release of releases) { + const built = await buildReleaseFile(opts.repo, release, deps) + if (!built) continue + await opts.writeFile(built.path, built.contents) + written.push(built.path) + if (built.warning) warnings.push(built.warning) + } + return { written, warnings } +} + +module.exports = { run } diff --git a/changelog-release-pr/scripts/run.test.cjs b/changelog-release-pr/scripts/run.test.cjs new file mode 100644 index 0000000..1e563ef --- /dev/null +++ b/changelog-release-pr/scripts/run.test.cjs @@ -0,0 +1,60 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const { run } = require('./run.cjs') + +const releases = [ + { tag_name: 'python/v1.42.0', published_at: '2026-06-01T00:00:00Z', html_url: 'h1', body: '* feat: a by @x in https://github.com/strands-agents/sdk-python/pull/1\n' }, + { tag_name: 'python-wasm/v0.0.1', published_at: '2026-06-02T00:00:00Z', html_url: 'h2', body: '' }, +] + +function fakeClient() { + return { + listReleases: async () => releases, + getRelease: async (_r, tag) => releases.find((x) => x.tag_name === tag) || null, + getPr: async () => ({ labels: ['area-model'], merge_commit_sha: 'abc1234', user: 'x' }), + } +} + +test('backfill writes one file per in-scope release', async () => { + const written = {} + const res = await run({ + repo: 'strands-agents/harness-sdk', mode: 'backfill', client: fakeClient(), + readExisting: async () => null, + writeFile: async (p, c) => { written[p] = c }, + }) + assert.deepEqual(Object.keys(written), ['site/src/content/changelog/harness/python-v1.42.0.md']) + assert.match(written['site/src/content/changelog/harness/python-v1.42.0.md'], /area-model|area/) // enriched + assert.deepEqual(res.warnings, []) +}) + +test('single mode writes only the given tag', async () => { + const written = {} + await run({ + repo: 'strands-agents/harness-sdk', mode: 'single', tag: 'python/v1.42.0', + client: fakeClient(), readExisting: async () => null, + writeFile: async (p, c) => { written[p] = c }, + }) + assert.deepEqual(Object.keys(written), ['site/src/content/changelog/harness/python-v1.42.0.md']) +}) + +test('single mode with unknown tag writes nothing', async () => { + const written = {} + const res = await run({ + repo: 'strands-agents/harness-sdk', mode: 'single', tag: 'python/v9.9.9', + client: fakeClient(), readExisting: async () => null, + writeFile: async (p, c) => { written[p] = c }, + }) + assert.deepEqual(Object.keys(written), []) + assert.deepEqual(res.written, []) +}) + +test('collects drift warnings', async () => { + const drifty = [{ tag_name: 'v1.0.0', published_at: '2026-01-01T00:00:00Z', html_url: 'h', body: '* a #1\n* b #2\n* c #3\n' }] + const client = { listReleases: async () => drifty, getRelease: async () => null, getPr: async () => null } + const res = await run({ + repo: 'strands-agents/harness-sdk', mode: 'backfill', client, + readExisting: async () => null, writeFile: async () => {}, + }) + assert.equal(res.warnings.length, 1) + assert.match(res.warnings[0], /parsed 0 of 3/) +}) From 208e9d1006730d7c3311bfd4e6d8fc84d20b6ecf Mon Sep 17 00:00:00 2001 From: Jonathan Segev <jsegev01@gmail.com> Date: Thu, 11 Jun 2026 15:36:37 -0400 Subject: [PATCH 07/13] =?UTF-8?q?feat(changelog):=20composite=20action=20?= =?UTF-8?q?=E2=80=94=20generate=20and=20open=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog-release-pr/action.yml | 72 +++++++++++++++++++ changelog-release-pr/scripts/run-action.cjs | 80 +++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 changelog-release-pr/action.yml create mode 100644 changelog-release-pr/scripts/run-action.cjs diff --git a/changelog-release-pr/action.yml b/changelog-release-pr/action.yml new file mode 100644 index 0000000..89482b1 --- /dev/null +++ b/changelog-release-pr/action.yml @@ -0,0 +1,72 @@ +name: 'Changelog Release PR' +description: 'Parse a GitHub release into the harness-sdk changelog collection and open a PR' +inputs: + source-repo: + description: 'owner/repo the release belongs to (e.g. strands-agents/evals)' + required: true + tag: + description: 'Release tag to sync (single mode). Omit for backfill.' + required: false + default: '' + mode: + description: 'single | backfill' + required: false + default: 'single' + github-token: + description: 'Token to read releases/PRs and open the PR. Needs pull-requests:write (and contents:write) on target-repo.' + required: true + target-repo: + description: 'Repo to open the changelog PR against' + required: false + default: 'strands-agents/harness-sdk' +runs: + using: 'composite' + steps: + - name: Checkout target repo + uses: actions/checkout@v6 + with: + repository: ${{ inputs.target-repo }} + token: ${{ inputs.github-token }} + path: target + fetch-depth: 0 + + - name: Checkout devtools (scripts) + uses: actions/checkout@v6 + with: + repository: strands-agents/devtools + ref: main + sparse-checkout: | + changelog-release-pr/scripts + path: devtools + persist-credentials: false + + - name: Generate changelog files + id: generate + uses: actions/github-script@v8 + env: + SOURCE_REPO: ${{ inputs.source-repo }} + TAG: ${{ inputs.tag }} + MODE: ${{ inputs.mode }} + TARGET_DIR: ${{ github.workspace }}/target + with: + github-token: ${{ inputs.github-token }} + script: | + const runAction = require('./devtools/changelog-release-pr/scripts/run-action.cjs') + await runAction(github, context, core) + + - name: Open PR + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ inputs.github-token }} + path: target + branch: changelog/sync-${{ inputs.source-repo == 'strands-agents/evals' && 'evals' || 'harness' }}-${{ inputs.tag || 'backfill' }} + title: "docs(changelog): sync ${{ inputs.source-repo }} ${{ inputs.tag || 'backfill' }}" + body: | + Automated changelog sync for `${{ inputs.source-repo }}` ${{ inputs.tag || '(backfill)' }}. + + Add a curated `highlights:` block to any release file before merging if desired. + + ${{ steps.generate.outputs.warnings != '' && '⚠️ Parser warnings were logged in the workflow run — review before merge.' || '' }} + commit-message: "docs(changelog): sync ${{ inputs.source-repo }} ${{ inputs.tag || 'backfill' }}" + delete-branch: true + draft: false diff --git a/changelog-release-pr/scripts/run-action.cjs b/changelog-release-pr/scripts/run-action.cjs new file mode 100644 index 0000000..521ebca --- /dev/null +++ b/changelog-release-pr/scripts/run-action.cjs @@ -0,0 +1,80 @@ +// github-script entry: assumes `github` (Octokit), `context`, and `core` are +// provided by actions/github-script (same convention as process-input.cjs). +// Builds a real client + fs ops and delegates to run.cjs. Inputs come from env +// (set by the composite action): SOURCE_REPO, MODE, TAG, TARGET_DIR. + +const fs = require('fs') +const path = require('path') +const { run } = require('./run.cjs') + +function splitRepo(full) { + const [owner, repo] = full.split('/') + return { owner, repo } +} + +async function runAction(github, context, core) { + const sourceRepo = process.env.SOURCE_REPO + const mode = process.env.MODE === 'backfill' ? 'backfill' : 'single' + const tag = process.env.TAG || undefined + const targetDir = process.env.TARGET_DIR + + const client = { + listReleases: async (repoFull) => { + const { owner, repo } = splitRepo(repoFull) + return github.paginate(github.rest.repos.listReleases, { owner, repo, per_page: 100 }) + }, + getRelease: async (repoFull, t) => { + const { owner, repo } = splitRepo(repoFull) + try { + const res = await github.rest.repos.getReleaseByTag({ owner, repo, tag: t }) + return res.data + } catch (e) { + if (e.status === 404) return null + throw e + } + }, + getPr: async (repoFull, num) => { + const { owner, repo } = splitRepo(repoFull) + try { + const res = await github.rest.pulls.get({ owner, repo, pull_number: num }) + const pr = res.data + return { + labels: (pr.labels || []).map((l) => l.name), + merge_commit_sha: pr.merge_commit_sha, + user: pr.user ? pr.user.login : null, + } + } catch (e) { + if (e.status === 404) return null + throw e + } + }, + } + + const result = await run({ + repo: sourceRepo, + mode, + tag, + client, + readExisting: async (p) => { + try { + return fs.readFileSync(path.join(targetDir, p), 'utf8') + } catch { + return null + } + }, + writeFile: async (p, contents) => { + const full = path.join(targetDir, p) + fs.mkdirSync(path.dirname(full), { recursive: true }) + fs.writeFileSync(full, contents) + }, + }) + + core.info(`changelog: wrote ${result.written.length} file(s)`) + for (const w of result.warnings) core.warning(w) + // Expose for the PR body. + core.setOutput('written_count', String(result.written.length)) + core.setOutput('warnings', result.warnings.join('\n')) + return result +} + +module.exports = runAction From 696aeac54746c4be5448acbdd75572f21e8c5b12 Mon Sep 17 00:00:00 2001 From: Jonathan Segev <jsegev01@gmail.com> Date: Thu, 11 Jun 2026 15:41:07 -0400 Subject: [PATCH 08/13] fix(changelog): normalize CRLF line endings in release body parsing --- changelog-release-pr/scripts/parse-release-body.cjs | 5 +++-- changelog-release-pr/scripts/parse-release-body.test.cjs | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/changelog-release-pr/scripts/parse-release-body.cjs b/changelog-release-pr/scripts/parse-release-body.cjs index 13f9857..6aa9126 100644 --- a/changelog-release-pr/scripts/parse-release-body.cjs +++ b/changelog-release-pr/scripts/parse-release-body.cjs @@ -17,7 +17,8 @@ const KNOWN_TYPES = new Set(['feat', 'fix', 'docs', 'perf', 'refactor', 'test', function parseReleaseBody(body) { if (!body) return [] const out = [] - for (const raw of body.split('\n')) { + // GitHub release bodies use CRLF; normalize so trailing \r doesn't break matches. + for (const raw of body.replace(/\r\n?/g, '\n').split('\n')) { const li = raw.match(LINE) if (!li) continue const tail = li[1].trim().match(TAIL) @@ -53,7 +54,7 @@ const LOOSE_ENTRY = /(@[\w-]+|#\d+|\/pull\/\d+)/ function countChangelogBullets(body) { if (!body) return 0 let n = 0 - for (const raw of body.split('\n')) { + for (const raw of body.replace(/\r\n?/g, '\n').split('\n')) { const li = raw.match(LINE) if (li && LOOSE_ENTRY.test(li[1])) n++ } diff --git a/changelog-release-pr/scripts/parse-release-body.test.cjs b/changelog-release-pr/scripts/parse-release-body.test.cjs index da7f4f5..82fb823 100644 --- a/changelog-release-pr/scripts/parse-release-body.test.cjs +++ b/changelog-release-pr/scripts/parse-release-body.test.cjs @@ -53,6 +53,15 @@ test('countChangelogBullets counts entry-like bullets, ignores notes', () => { assert.equal(countChangelogBullets(''), 0) }) +test('handles CRLF line endings (real GitHub bodies)', () => { + const crlf = "## What's Changed\r\n\r\n* feat(model): x by @a in https://github.com/o/r/pull/1\r\n* fix: y by @b in https://github.com/o/r/pull/2\r\n" + const lines = parseReleaseBody(crlf) + assert.equal(lines.length, 2) + assert.equal(lines[0].scope, 'model') + assert.equal(lines[0].title, 'x') // no trailing \r + assert.equal(countChangelogBullets(crlf), 2) +}) + test('countChangelogBullets stays loose vs strict parser (drift signal)', () => { // PR refs as #123 instead of full URL: loose counter sees them, strict parser does not. const drifted = '* updated thing #11\n* fixed thing #12\n* added thing #13' From 8c8bd485a7c2b73ceea1ac5859fccc3c7a6faec2 Mon Sep 17 00:00:00 2001 From: Jonathan Segev <jsegev01@gmail.com> Date: Thu, 11 Jun 2026 15:48:58 -0400 Subject: [PATCH 09/13] fix(changelog): quote YAML-significant and reserved-word values in rendered frontmatter --- .../scripts/render-markdown.cjs | 22 +++++++++++----- .../scripts/render-markdown.test.cjs | 26 +++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/changelog-release-pr/scripts/render-markdown.cjs b/changelog-release-pr/scripts/render-markdown.cjs index 2646a78..09f4b2e 100644 --- a/changelog-release-pr/scripts/render-markdown.cjs +++ b/changelog-release-pr/scripts/render-markdown.cjs @@ -5,16 +5,26 @@ // on re-sync while refreshing the parsed entries/urls. function q(s) { - return `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` + return `"${String(s) + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\t/g, '\\t') + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n')}"` } -// Bareword if safe for YAML, else quoted. Anything starting with a digit or -// containing YAML-significant chars gets quoted. +// YAML 1.1 words that, left bare, parse as booleans/null instead of strings. +const YAML_RESERVED = new Set([ + 'true', 'false', 'yes', 'no', 'on', 'off', 'null', 'none', '~', +]) + +// Bareword if safe for YAML, else quoted. Quote anything that starts with a +// digit, contains YAML-significant chars, or is a reserved bool/null word. function scalar(v) { if (v === null || v === undefined) return 'null' if (typeof v === 'number') return String(v) if (v === '') return '""' - if (/^[\w.@/-]+$/.test(v) && !/^\d/.test(v)) return v + if (/^[\w.@/-]+$/.test(v) && !/^\d/.test(v) && !YAML_RESERVED.has(v.toLowerCase())) return v return q(v) } @@ -23,7 +33,7 @@ function flowEntry(e) { `type: ${e.type}`, `breaking: ${e.breaking === true}`, `scope: ${e.scope ? scalar(e.scope) : 'null'}`, - `areas: [${e.areas.join(', ')}]`, + `areas: [${e.areas.map(scalar).join(', ')}]`, `title: ${q(e.title)}`, `pr: ${e.pr == null ? 'null' : e.pr}`, `prUrl: ${e.prUrl ? q(e.prUrl) : 'null'}`, @@ -43,7 +53,7 @@ function renderMarkdown(f, body = '') { lines.push(`sdk: ${f.sdk}`) if (f.language) lines.push(`language: ${f.language}`) lines.push(`version: ${q(f.version)}`) - lines.push(`tag: ${f.tag}`) + lines.push(`tag: ${scalar(f.tag)}`) lines.push(`date: ${f.date}`) lines.push(`releaseUrl: ${f.releaseUrl}`) lines.push(`packageUrl: ${f.packageUrl}`) diff --git a/changelog-release-pr/scripts/render-markdown.test.cjs b/changelog-release-pr/scripts/render-markdown.test.cjs index 45c4c4e..3dbb9ca 100644 --- a/changelog-release-pr/scripts/render-markdown.test.cjs +++ b/changelog-release-pr/scripts/render-markdown.test.cjs @@ -61,6 +61,32 @@ test('null pr/commit/author render as null', () => { assert.match(md, /scope: null/) }) +test('quotes areas that contain YAML-significant chars', () => { + const md = renderMarkdown({ + ...file, + entries: [{ ...file.entries[0], areas: ['model', 'a,b', 'weird]bracket', 'has space'] }], + }) + // commas/brackets/spaces inside a label must be quoted so the flow seq stays valid + assert.match(md, /areas: \[model, "a,b", "weird\]bracket", "has space"\]/) +}) + +test('quotes YAML reserved bool/null words in scope and areas', () => { + const md = renderMarkdown({ + ...file, + entries: [{ ...file.entries[0], scope: 'on', areas: ['yes', 'null', 'model'] }], + }) + assert.match(md, /scope: "on"/) + assert.match(md, /areas: \["yes", "null", model\]/) +}) + +test('escapes quotes and newlines in titles', () => { + const md = renderMarkdown({ + ...file, + entries: [{ ...file.entries[0], title: 'add "quoted" thing' }], + }) + assert.match(md, /title: "add \\"quoted\\" thing"/) +}) + test('mergePreserving keeps existing highlights + body, refreshes entries', () => { const existing = `--- sdk: harness From e68f90a730d688d1c0cfc973e6a38bd8a7b29d33 Mon Sep 17 00:00:00 2001 From: Jonathan Segev <jsegev01@gmail.com> Date: Thu, 11 Jun 2026 16:17:38 -0400 Subject: [PATCH 10/13] fix(changelog): sanitize PR branch name from tag, harden backfill against transient PR errors --- changelog-release-pr/action.yml | 2 +- changelog-release-pr/scripts/run-action.cjs | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/changelog-release-pr/action.yml b/changelog-release-pr/action.yml index 89482b1..f1b79d4 100644 --- a/changelog-release-pr/action.yml +++ b/changelog-release-pr/action.yml @@ -59,7 +59,7 @@ runs: with: token: ${{ inputs.github-token }} path: target - branch: changelog/sync-${{ inputs.source-repo == 'strands-agents/evals' && 'evals' || 'harness' }}-${{ inputs.tag || 'backfill' }} + branch: ${{ steps.generate.outputs.branch }} title: "docs(changelog): sync ${{ inputs.source-repo }} ${{ inputs.tag || 'backfill' }}" body: | Automated changelog sync for `${{ inputs.source-repo }}` ${{ inputs.tag || '(backfill)' }}. diff --git a/changelog-release-pr/scripts/run-action.cjs b/changelog-release-pr/scripts/run-action.cjs index 521ebca..bd506bc 100644 --- a/changelog-release-pr/scripts/run-action.cjs +++ b/changelog-release-pr/scripts/run-action.cjs @@ -44,8 +44,11 @@ async function runAction(github, context, core) { user: pr.user ? pr.user.login : null, } } catch (e) { - if (e.status === 404) return null - throw e + // 404 (deleted PR) and transient errors (403 rate-limit, 5xx) should + // degrade this one entry, not abort a whole backfill. Enrichment is + // best-effort; the next sync re-enriches. + if (e.status !== 404) core.warning(`PR ${repoFull}#${num}: ${e.status || e.message} — skipping enrichment`) + return null } }, } @@ -71,9 +74,12 @@ async function runAction(github, context, core) { core.info(`changelog: wrote ${result.written.length} file(s)`) for (const w of result.warnings) core.warning(w) - // Expose for the PR body. + // Expose for the PR body + a git-safe branch name (tags contain '/' and '.'). + const sdkSlug = sourceRepo.endsWith('/evals') ? 'evals' : 'harness' + const tagSlug = (tag || 'backfill').replace(/[^A-Za-z0-9._-]+/g, '-') core.setOutput('written_count', String(result.written.length)) core.setOutput('warnings', result.warnings.join('\n')) + core.setOutput('branch', `changelog/sync-${sdkSlug}-${tagSlug}`) return result } From dc3ba447aa8f4b9827c0a2bd7229da0dfde306b2 Mon Sep 17 00:00:00 2001 From: Jonathan Segev <jsegev01@gmail.com> Date: Fri, 12 Jun 2026 08:41:29 -0400 Subject: [PATCH 11/13] fix(changelog): skip draft releases (null published_at) in backfill --- changelog-release-pr/scripts/run.cjs | 4 ++++ changelog-release-pr/scripts/run.test.cjs | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/changelog-release-pr/scripts/run.cjs b/changelog-release-pr/scripts/run.cjs index 733c02a..de94b79 100644 --- a/changelog-release-pr/scripts/run.cjs +++ b/changelog-release-pr/scripts/run.cjs @@ -26,6 +26,10 @@ async function run(opts) { releases = r ? [r] : [] } + // listReleases includes drafts (no published_at) — skip them; a changelog + // only covers published releases. + releases = releases.filter((r) => r && r.published_at) + const deps = { enrich: (prRepo, pr) => enrichFromPr(prRepo, pr, opts.client.getPr), readExisting: opts.readExisting, diff --git a/changelog-release-pr/scripts/run.test.cjs b/changelog-release-pr/scripts/run.test.cjs index 1e563ef..fbd0b36 100644 --- a/changelog-release-pr/scripts/run.test.cjs +++ b/changelog-release-pr/scripts/run.test.cjs @@ -48,6 +48,21 @@ test('single mode with unknown tag writes nothing', async () => { assert.deepEqual(res.written, []) }) +test('skips draft releases (null published_at) without crashing', async () => { + const withDraft = [ + { tag_name: 'v1.0.0', published_at: null, html_url: 'h', body: '* feat: x by @a in https://github.com/o/r/pull/1\n' }, + { tag_name: 'v0.9.0', published_at: '2026-01-01T00:00:00Z', html_url: 'h', body: '* fix: y by @b in https://github.com/o/r/pull/2\n' }, + ] + const client = { listReleases: async () => withDraft, getRelease: async () => null, getPr: async () => null } + const written = {} + const res = await run({ + repo: 'strands-agents/evals', mode: 'backfill', client, + readExisting: async () => null, writeFile: async (p, c) => { written[p] = c }, + }) + // only the published one is written + assert.deepEqual(res.written, ['site/src/content/changelog/evals/v0.9.0.md']) +}) + test('collects drift warnings', async () => { const drifty = [{ tag_name: 'v1.0.0', published_at: '2026-01-01T00:00:00Z', html_url: 'h', body: '* a #1\n* b #2\n* c #3\n' }] const client = { listReleases: async () => drifty, getRelease: async () => null, getPr: async () => null } From 3f2ca48f2b795a2f654b61250b253ef99bcb5279 Mon Sep 17 00:00:00 2001 From: Jonathan Segev <jsegev01@gmail.com> Date: Fri, 12 Jun 2026 09:50:16 -0400 Subject: [PATCH 12/13] feat(changelog): add skip-existing mode for cheap non-regressing cron backstop --- changelog-release-pr/action.yml | 5 +++++ .../scripts/build-release-file.cjs | 14 +++++++++++--- changelog-release-pr/scripts/run-action.cjs | 2 ++ changelog-release-pr/scripts/run.cjs | 2 ++ changelog-release-pr/scripts/run.test.cjs | 19 ++++++++++++++++++- 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/changelog-release-pr/action.yml b/changelog-release-pr/action.yml index f1b79d4..0ddce1a 100644 --- a/changelog-release-pr/action.yml +++ b/changelog-release-pr/action.yml @@ -12,6 +12,10 @@ inputs: description: 'single | backfill' required: false default: 'single' + skip-existing: + description: 'In backfill mode, only generate files for releases without one (cheap daily backstop; avoids re-enrichment API cost and never regresses existing files). Set false for a full refresh.' + required: false + default: 'false' github-token: description: 'Token to read releases/PRs and open the PR. Needs pull-requests:write (and contents:write) on target-repo.' required: true @@ -47,6 +51,7 @@ runs: SOURCE_REPO: ${{ inputs.source-repo }} TAG: ${{ inputs.tag }} MODE: ${{ inputs.mode }} + SKIP_EXISTING: ${{ inputs.skip-existing }} TARGET_DIR: ${{ github.workspace }}/target with: github-token: ${{ inputs.github-token }} diff --git a/changelog-release-pr/scripts/build-release-file.cjs b/changelog-release-pr/scripts/build-release-file.cjs index cb543d2..5e93a4c 100644 --- a/changelog-release-pr/scripts/build-release-file.cjs +++ b/changelog-release-pr/scripts/build-release-file.cjs @@ -13,13 +13,23 @@ function fileNameFor(sdk, language, version) { /** * @param {string} repo the SOURCE repo the release belongs to * @param {{tag_name:string, published_at:string, html_url:string, body:string|null}} release - * @param {{enrich:(prRepo:string,pr:number)=>Promise<{areas:string[],breaking:boolean,commit:string|null,author:string|null}>, readExisting:(path:string)=>Promise<string|null>}} deps + * @param {{enrich:(prRepo:string,pr:number)=>Promise<{areas:string[],breaking:boolean,commit:string|null,author:string|null}>, readExisting:(path:string)=>Promise<string|null>, skipExisting?:boolean}} deps * @returns {Promise<{path:string, contents:string, warning?:string}|null>} */ async function buildReleaseFile(repo, release, deps) { const meta = tagToMeta(repo, release.tag_name) if (!meta) return null + const path = `site/src/content/changelog/${fileNameFor(meta.sdk, meta.language, meta.version)}` + const existing = await deps.readExisting(path) + + // skipExisting (used by the daily cron backstop): only generate files for + // releases that don't have one yet. Checked BEFORE enrichment so a skipped + // release costs zero PR API calls, and existing files (possibly carrying + // richer enrichment from when labels were fresher) are never regressed by a + // rate-limited re-run. A full refresh is an explicit backfill dispatch. + if (deps.skipExisting && existing) return null + const parsed = parseReleaseBody(release.body) // Format-drift guard: if the body clearly has changelog bullets but few/none @@ -63,8 +73,6 @@ async function buildReleaseFile(repo, release, deps) { entries, } - const path = `site/src/content/changelog/${fileNameFor(meta.sdk, meta.language, meta.version)}` - const existing = await deps.readExisting(path) const contents = existing ? mergePreserving(file, existing) : renderMarkdown(file) return warning ? { path, contents, warning } : { path, contents } } diff --git a/changelog-release-pr/scripts/run-action.cjs b/changelog-release-pr/scripts/run-action.cjs index bd506bc..ebcb9ef 100644 --- a/changelog-release-pr/scripts/run-action.cjs +++ b/changelog-release-pr/scripts/run-action.cjs @@ -17,6 +17,7 @@ async function runAction(github, context, core) { const mode = process.env.MODE === 'backfill' ? 'backfill' : 'single' const tag = process.env.TAG || undefined const targetDir = process.env.TARGET_DIR + const skipExisting = process.env.SKIP_EXISTING === 'true' const client = { listReleases: async (repoFull) => { @@ -57,6 +58,7 @@ async function runAction(github, context, core) { repo: sourceRepo, mode, tag, + skipExisting, client, readExisting: async (p) => { try { diff --git a/changelog-release-pr/scripts/run.cjs b/changelog-release-pr/scripts/run.cjs index de94b79..f5d64f8 100644 --- a/changelog-release-pr/scripts/run.cjs +++ b/changelog-release-pr/scripts/run.cjs @@ -11,6 +11,7 @@ const { enrichFromPr } = require('./enrich.cjs') * repo:string, * mode:'single'|'backfill', * tag?:string, + * skipExisting?:boolean, * client:{ listReleases:(repo:string)=>Promise<any[]>, getRelease:(repo:string,tag:string)=>Promise<any|null>, getPr:(repo:string,num:number)=>Promise<any|null> }, * readExisting:(path:string)=>Promise<string|null>, * writeFile:(path:string,contents:string)=>Promise<void>, @@ -33,6 +34,7 @@ async function run(opts) { const deps = { enrich: (prRepo, pr) => enrichFromPr(prRepo, pr, opts.client.getPr), readExisting: opts.readExisting, + skipExisting: opts.skipExisting === true, } const written = [] diff --git a/changelog-release-pr/scripts/run.test.cjs b/changelog-release-pr/scripts/run.test.cjs index fbd0b36..55af032 100644 --- a/changelog-release-pr/scripts/run.test.cjs +++ b/changelog-release-pr/scripts/run.test.cjs @@ -23,10 +23,27 @@ test('backfill writes one file per in-scope release', async () => { writeFile: async (p, c) => { written[p] = c }, }) assert.deepEqual(Object.keys(written), ['site/src/content/changelog/harness/python-v1.42.0.md']) - assert.match(written['site/src/content/changelog/harness/python-v1.42.0.md'], /area-model|area/) // enriched + // enrichment landed: area-model label → areas: [model] + assert.match(written['site/src/content/changelog/harness/python-v1.42.0.md'], /areas: \[model\]/) assert.deepEqual(res.warnings, []) }) +test('skipExisting skips releases with files and never calls enrichment for them', async () => { + let prCalls = 0 + const client = { + ...fakeClient(), + getPr: async () => { prCalls++; return { labels: [], merge_commit_sha: 'abc1234', user: 'x' } }, + } + const written = {} + const res = await run({ + repo: 'strands-agents/harness-sdk', mode: 'backfill', skipExisting: true, client, + readExisting: async () => '---\nsdk: harness\n---\n', // every file already exists + writeFile: async (p, c) => { written[p] = c }, + }) + assert.deepEqual(res.written, []) + assert.equal(prCalls, 0) // existence checked BEFORE enrichment +}) + test('single mode writes only the given tag', async () => { const written = {} await run({ From c1d0d350c46556bd7276f1e38c24fbe20f02f43c Mon Sep 17 00:00:00 2001 From: Jonathan Segev <jsegev01@gmail.com> Date: Fri, 12 Jun 2026 11:09:32 -0400 Subject: [PATCH 13/13] feat(changelog): gate monorepo entries by SDK language from PR changed files --- .../scripts/build-release-file.cjs | 15 +++++- .../scripts/build-release-file.test.cjs | 48 +++++++++++++++++++ changelog-release-pr/scripts/enrich.cjs | 37 +++++++++++--- changelog-release-pr/scripts/enrich.test.cjs | 26 +++++++++- changelog-release-pr/scripts/run-action.cjs | 15 +++++- 5 files changed, 131 insertions(+), 10 deletions(-) diff --git a/changelog-release-pr/scripts/build-release-file.cjs b/changelog-release-pr/scripts/build-release-file.cjs index 5e93a4c..2166a4c 100644 --- a/changelog-release-pr/scripts/build-release-file.cjs +++ b/changelog-release-pr/scripts/build-release-file.cjs @@ -41,12 +41,25 @@ async function buildReleaseFile(repo, release, deps) { ? `${release.tag_name}: parsed ${parsed.length} of ${bullets} changelog bullets — release-note format may have changed; review before merge.` : undefined + // Monorepo releases (prefixed tags) list every merged PR regardless of + // language, so gate entries by which SDK dirs the PR actually touched: + // python stream keeps python-touching PRs, ts stream keeps ts-touching, + // both → both, site/ci/docs-only (empty languages) → omitted everywhere, + // unknown (file info unavailable) → kept (degrade open). Pre-monorepo + // bare-`v` tags and evals are single-language releases — no filtering. + const isMonorepoStream = + meta.sdk === 'harness' && + (release.tag_name.startsWith('python/') || release.tag_name.startsWith('typescript/')) + const entries = [] for (const p of parsed) { const prRepo = p.prRepo || repo const enr = p.pr ? await deps.enrich(prRepo, p.pr) - : { areas: [], breaking: false, commit: null, author: null } + : { areas: [], breaking: false, commit: null, author: null, languages: null } + if (isMonorepoStream && Array.isArray(enr.languages) && !enr.languages.includes(meta.language)) { + continue + } const breaking = p.breaking || enr.breaking entries.push({ type: breaking && p.type === 'other' ? 'breaking' : p.type, diff --git a/changelog-release-pr/scripts/build-release-file.test.cjs b/changelog-release-pr/scripts/build-release-file.test.cjs index 09e4468..aae4990 100644 --- a/changelog-release-pr/scripts/build-release-file.test.cjs +++ b/changelog-release-pr/scripts/build-release-file.test.cjs @@ -55,6 +55,54 @@ test('flags format-drift warning when bullets parse poorly', async () => { assert.match(r.warning, /parsed 0 of 3/) }) +test('monorepo release filters entries by stream language from PR files', async () => { + const body = [ + '* feat: py thing by @a in https://github.com/strands-agents/harness-sdk/pull/1', + '* feat: ts thing by @b in https://github.com/strands-agents/harness-sdk/pull/2', + '* feat: both thing by @c in https://github.com/strands-agents/harness-sdk/pull/3', + '* chore: site thing by @d in https://github.com/strands-agents/harness-sdk/pull/4', + '* fix: unknown thing by @e in https://github.com/strands-agents/harness-sdk/pull/5', + ].join('\n') + const langByPr = { 1: ['python'], 2: ['typescript'], 3: ['python', 'typescript'], 4: [], 5: null } + const deps = { + enrich: async (_repo, pr) => ({ areas: [], breaking: false, commit: null, author: null, languages: langByPr[pr] }), + readExisting: async () => null, + } + const py = await buildReleaseFile('strands-agents/harness-sdk', + { tag_name: 'python/v1.43.0', published_at: '2026-06-12T00:00:00Z', html_url: 'h', body }, deps) + // python stream: keeps py(1), both(3), unknown(5); drops ts(2) and site-only(4) + assert.match(py.contents, /py thing/) + assert.match(py.contents, /both thing/) + assert.match(py.contents, /unknown thing/) + assert.doesNotMatch(py.contents, /ts thing/) + assert.doesNotMatch(py.contents, /site thing/) + + const ts = await buildReleaseFile('strands-agents/harness-sdk', + { tag_name: 'typescript/v1.5.0', published_at: '2026-06-12T00:00:00Z', html_url: 'h', body }, deps) + // typescript stream: keeps ts(2), both(3), unknown(5) + assert.match(ts.contents, /ts thing/) + assert.match(ts.contents, /both thing/) + assert.match(ts.contents, /unknown thing/) + assert.doesNotMatch(ts.contents, /py thing/) + assert.doesNotMatch(ts.contents, /site thing/) +}) + +test('pre-monorepo and evals releases are not language-filtered', async () => { + const deps = { + // even if files say typescript, a single-language-repo release keeps everything + enrich: async () => ({ areas: [], breaking: false, commit: null, author: null, languages: ['typescript'] }), + readExisting: async () => null, + } + const old = await buildReleaseFile('strands-agents/harness-sdk', + { tag_name: 'v1.9.1', published_at: '2026-01-01T00:00:00Z', html_url: 'h', + body: '* feat: old thing by @a in https://github.com/strands-agents/sdk-python/pull/9' }, deps) + assert.match(old.contents, /old thing/) + const ev = await buildReleaseFile('strands-agents/evals', + { tag_name: 'v0.2.1', published_at: '2026-01-01T00:00:00Z', html_url: 'h', + body: '* feat: eval thing by @a in https://github.com/strands-agents/evals/pull/9' }, deps) + assert.match(ev.contents, /eval thing/) +}) + test('breaking marker promotes type when no conventional type', async () => { // a non-conventional line that the PR labels mark breaking → type becomes 'breaking' const r = await buildReleaseFile('strands-agents/harness-sdk', diff --git a/changelog-release-pr/scripts/enrich.cjs b/changelog-release-pr/scripts/enrich.cjs index ccf3fa2..53cb5c0 100644 --- a/changelog-release-pr/scripts/enrich.cjs +++ b/changelog-release-pr/scripts/enrich.cjs @@ -1,29 +1,52 @@ // Enrich a parsed line from its linked PR: area-* labels, breaking flag, -// short merge-commit SHA, author. Pure given an injected fetcher (no network +// short merge-commit SHA, author, and (for monorepo PRs) which SDK languages +// the PR actually touches — derived from changed-file paths because language +// labels are too sparse to rely on. Pure given an injected fetcher (no network // here), so it's unit-testable. Degrades to empty enrichment when the PR can't // be fetched (deleted PR, missing permissions, old repo) — callers still emit -// the entry, just without areas/commit. +// the entry, just without areas/commit, and with languages unknown. /** - * @typedef {{labels:string[], merge_commit_sha:string|null, user:string|null}} PrData + * @typedef {{labels:string[], merge_commit_sha:string|null, user:string|null, files?:string[]}} PrData * @typedef {(repo:string, num:number)=>Promise<PrData|null>} PrFetcher */ +// Monorepo top-level dirs that mark a PR as touching an SDK language. +const LANGUAGE_DIRS = { + 'strands-py': 'python', + 'strands-ts': 'typescript', +} + +/** + * Derive SDK languages from changed-file paths. Returns: + * - string[] of languages (possibly empty = site/ci/docs-only PR) + * - null when file info is unavailable (unknown — callers should not filter) + */ +function languagesFromFiles(files) { + if (!Array.isArray(files)) return null + const langs = new Set() + for (const f of files) { + const top = String(f).split('/')[0] + if (LANGUAGE_DIRS[top]) langs.add(LANGUAGE_DIRS[top]) + } + return [...langs] +} + /** * @param {string} repo * @param {number} num * @param {PrFetcher} fetcher - * @returns {Promise<{areas:string[], breaking:boolean, commit:string|null, author:string|null}>} + * @returns {Promise<{areas:string[], breaking:boolean, commit:string|null, author:string|null, languages:string[]|null}>} */ async function enrichFromPr(repo, num, fetcher) { const pr = await fetcher(repo, num) - if (!pr) return { areas: [], breaking: false, commit: null, author: null } + if (!pr) return { areas: [], breaking: false, commit: null, author: null, languages: null } const areas = (pr.labels || []) .filter((l) => l.startsWith('area-')) .map((l) => l.slice('area-'.length)) const breaking = (pr.labels || []).some((l) => l.toLowerCase() === 'breaking change') const commit = pr.merge_commit_sha ? pr.merge_commit_sha.slice(0, 7) : null - return { areas, breaking, commit, author: pr.user || null } + return { areas, breaking, commit, author: pr.user || null, languages: languagesFromFiles(pr.files) } } -module.exports = { enrichFromPr } +module.exports = { enrichFromPr, languagesFromFiles } diff --git a/changelog-release-pr/scripts/enrich.test.cjs b/changelog-release-pr/scripts/enrich.test.cjs index 10db4ad..d808467 100644 --- a/changelog-release-pr/scripts/enrich.test.cjs +++ b/changelog-release-pr/scripts/enrich.test.cjs @@ -28,7 +28,7 @@ test('detects breaking label', async () => { test('missing pr degrades gracefully (fetcher returns null)', async () => { const f = async () => null const e = await enrichFromPr('r', 1, f) - assert.deepEqual(e, { areas: [], breaking: false, commit: null, author: null }) + assert.deepEqual(e, { areas: [], breaking: false, commit: null, author: null, languages: null }) }) test('no merge sha yields null commit', async () => { @@ -37,3 +37,27 @@ test('no merge sha yields null commit', async () => { assert.equal(e.commit, null) assert.equal(e.author, null) }) + +test('derives languages from monorepo top-level dirs', async () => { + const f = async () => ({ labels: [], merge_commit_sha: 'abc1234', user: 'x', files: ['strands-py/src/agent.py', 'strands-py/tests/t.py'] }) + const e = await enrichFromPr('r', 1, f) + assert.deepEqual(e.languages, ['python']) +}) + +test('PR touching both sdk dirs yields both languages', async () => { + const f = async () => ({ labels: [], merge_commit_sha: 'abc1234', user: 'x', files: ['strands-py/a.py', 'strands-ts/b.ts'] }) + const e = await enrichFromPr('r', 1, f) + assert.deepEqual(e.languages.sort(), ['python', 'typescript']) +}) + +test('site/ci/docs-only PR yields empty languages', async () => { + const f = async () => ({ labels: [], merge_commit_sha: 'abc1234', user: 'x', files: ['site/src/page.astro', '.github/workflows/x.yml', 'designs/d.md'] }) + const e = await enrichFromPr('r', 1, f) + assert.deepEqual(e.languages, []) +}) + +test('missing files info yields null languages (unknown — keep everywhere)', async () => { + const f = async () => ({ labels: [], merge_commit_sha: 'abc1234', user: 'x' }) + const e = await enrichFromPr('r', 1, f) + assert.equal(e.languages, null) +}) diff --git a/changelog-release-pr/scripts/run-action.cjs b/changelog-release-pr/scripts/run-action.cjs index ebcb9ef..e520ad6 100644 --- a/changelog-release-pr/scripts/run-action.cjs +++ b/changelog-release-pr/scripts/run-action.cjs @@ -39,11 +39,24 @@ async function runAction(github, context, core) { try { const res = await github.rest.pulls.get({ owner, repo, pull_number: num }) const pr = res.data - return { + const out = { labels: (pr.labels || []).map((l) => l.name), merge_commit_sha: pr.merge_commit_sha, user: pr.user ? pr.user.login : null, } + // Changed files drive language gating for monorepo releases. Only the + // monorepo needs them — pre-monorepo/evals repos are single-language + // and build skips filtering there, so don't spend the extra call. + if (repoFull === 'strands-agents/harness-sdk') { + try { + const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: num, per_page: 100 }) + out.files = files.map((f) => f.filename) + } catch (e) { + // Leave files undefined → languages null → entry kept everywhere. + core.warning(`PR ${repoFull}#${num} files: ${e.status || e.message} — language gating skipped`) + } + } + return out } catch (e) { // 404 (deleted PR) and transient errors (403 rate-limit, 5xx) should // degrade this one entry, not abort a whole backfill. Enrichment is