Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions changelog-release-pr/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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'
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
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 }}
SKIP_EXISTING: ${{ inputs.skip-existing }}
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: ${{ 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)' }}.

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
80 changes: 80 additions & 0 deletions changelog-release-pr/scripts/build-release-file.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// 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>, 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
// 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 contents = existing ? mergePreserving(file, existing) : renderMarkdown(file)
return warning ? { path, contents, warning } : { path, contents }
}

module.exports = { buildReleaseFile }
65 changes: 65 additions & 0 deletions changelog-release-pr/scripts/build-release-file.test.cjs
Original file line number Diff line number Diff line change
@@ -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/)
})
29 changes: 29 additions & 0 deletions changelog-release-pr/scripts/enrich.cjs
Original file line number Diff line number Diff line change
@@ -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 }
39 changes: 39 additions & 0 deletions changelog-release-pr/scripts/enrich.test.cjs
Original file line number Diff line number Diff line change
@@ -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)
})
64 changes: 64 additions & 0 deletions changelog-release-pr/scripts/parse-release-body.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Parse GitHub's auto-generated "What's Changed" release body into structured
// lines. Pure, dependency-free.
//
// Line shape: "* <type>(<scope>)!: <title> 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 = []
// 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)
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.replace(/\r\n?/g, '\n').split('\n')) {
const li = raw.match(LINE)
if (li && LOOSE_ENTRY.test(li[1])) n++
}
return n
}

module.exports = { parseReleaseBody, countChangelogBullets }
Loading