diff --git a/README.md b/README.md index 0f9c41b..f97d87a 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ This repo serves as a central location for: | [`issue-labeler`](issue-labeler/) | Classify issues using an LLM and apply labels from a configurable allowlist | | [`authorization-check`](authorization-check/) | Check user authorization for workflow triggers | | [`strands-command`](strands-command/) | Run a Strands agent in GitHub Actions | +| [`changelog-release-pr`](changelog-release-pr/) | Parse a GitHub release into the harness-sdk changelog and open a PR | ## Documentation diff --git a/changelog-release-pr/README.md b/changelog-release-pr/README.md new file mode 100644 index 0000000..db00556 --- /dev/null +++ b/changelog-release-pr/README.md @@ -0,0 +1,48 @@ +# Changelog Release PR + +Composite action that turns a GitHub Release into a structured changelog entry +for the docs site in `strands-agents/harness-sdk` and opens a PR there. + +Pipeline (deterministic — no LLM): + +1. Fetch the release (single tag) or all releases (backfill) from `source-repo`. +2. Parse the auto-generated "What's Changed" body into structured entries + (conventional-commit type/scope, title, PR, author). "New Contributors" + lines are extracted separately and never become entries. +3. Enrich each entry from its PR: `area-*` labels → areas, `breaking change` + label, merge-commit SHA, author. For monorepo releases the PR's changed + files gate entries by language (`strands-py` → python stream, `strands-ts` → + typescript, both → both, neither → omitted; new contributors with neither + are kept in both — people aren't noise). Enrichment degrades gracefully when + a PR can't be fetched. +4. Render `site/src/content/changelog//.md` matching the harness-sdk + content-collection schema. Human-written `highlights:` blocks and markdown + bodies survive re-syncs. +5. Open a PR against `target-repo` via peter-evans/create-pull-request. + +## Inputs + +| Input | Required | Default | Notes | +|---|---|---|---| +| `source-repo` | yes | — | owner/repo the release belongs to | +| `tag` | single mode | `''` | release tag to sync | +| `mode` | no | `single` | `single` \| `backfill` | +| `skip-existing` | no | `false` | backfill only: generate just the missing files (zero PR-API cost for existing ones, never regresses enrichment). Used by the daily cron backstop. | +| `github-token` | yes | — | reads releases/PRs and opens the PR. Needs `contents:write` + `pull-requests:write` on `target-repo`. NOTE: PRs created with the default `GITHUB_TOKEN` don't trigger `pull_request` workflows (required checks won't run) — use an App/PAT token where that matters. | +| `target-repo` | no | `strands-agents/harness-sdk` | repo that hosts the changelog | + +## Consumers + +- `strands-agents/harness-sdk` `.github/workflows/changelog-sync.yml` — on + release + daily cron backstop (the cron also backstops evals). +- `strands-agents/evals` `.github/workflows/changelog-sync.yml` — cross-repo + PR into harness-sdk on each evals release. + +## Tests + +```bash +cd changelog-release-pr/scripts && node --test +``` + +Dependency-free `.cjs` modules run via `actions/github-script`; logic modules +are pure with injected fetchers/fs, so the suite runs without network. diff --git a/changelog-release-pr/action.yml b/changelog-release-pr/action.yml new file mode 100644 index 0000000..102342b --- /dev/null +++ b/changelog-release-pr/action.yml @@ -0,0 +1,82 @@ +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 + # Track the ref the CALLER pinned this action to, so pinning + # `uses: ...@` pins the scripts too (a hard-coded `main` here + # would let script behavior float regardless of the caller's pin). + ref: ${{ github.action_ref }} + 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 + # Third-party action receiving a write-scoped token — pinned to a full + # commit SHA (v7.0.x) rather than a movable tag. + uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # 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 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..e2f68d4 --- /dev/null +++ b/changelog-release-pr/scripts/build-release-file.cjs @@ -0,0 +1,113 @@ +// 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, parseNewContributors } = 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, 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 + + // 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, 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, + 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, + }) + } + + // New contributors: language-gate like entries, with one deliberate + // difference — a first PR that touches NO sdk dir (docs/ci/site) is still + // celebrated in BOTH streams (entries with no language are dropped as noise; + // people aren't noise). Unknown file info → also kept in both. + let newContributors = parseNewContributors(release.body) + if (isMonorepoStream) { + const gated = [] + for (const c of newContributors) { + // Use the PR's own repo (mirrors the entries path) — first-contribution + // links can point at the pre-monorepo repos. + const enr = await deps.enrich(c.prRepo || repo, c.pr) + const langs = enr.languages + if (!Array.isArray(langs) || langs.length === 0 || langs.includes(meta.language)) { + gated.push(c) + } + } + newContributors = gated + } + + 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, + newContributors, + } + + 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..1c39591 --- /dev/null +++ b/changelog-release-pr/scripts/build-release-file.test.cjs @@ -0,0 +1,158 @@ +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('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('new contributors are language-gated, but docs/ci-only ones appear in both streams', async () => { + const body = [ + '* feat: x by @a in https://github.com/strands-agents/harness-sdk/pull/1', + '', + '## New Contributors', + '* @pydev made their first contribution in https://github.com/strands-agents/harness-sdk/pull/10', + '* @tsdev made their first contribution in https://github.com/strands-agents/harness-sdk/pull/11', + '* @docsdev made their first contribution in https://github.com/strands-agents/harness-sdk/pull/12', + '* @mystery made their first contribution in https://github.com/strands-agents/harness-sdk/pull/13', + ].join('\n') + const langByPr = { 1: ['python'], 10: ['python'], 11: ['typescript'], 12: [], 13: null } + const deps = { + enrich: async (_r, 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: pydev (python), docsdev (neither → both), mystery (unknown → both); NOT tsdev + assert.match(py.contents, /login: pydev/) + assert.match(py.contents, /login: docsdev/) + assert.match(py.contents, /login: mystery/) + assert.doesNotMatch(py.contents, /login: tsdev/) + + 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) + assert.match(ts.contents, /login: tsdev/) + assert.match(ts.contents, /login: docsdev/) + assert.match(ts.contents, /login: mystery/) + assert.doesNotMatch(ts.contents, /login: pydev/) +}) + +test('new contributors flow into frontmatter, not entries', async () => { + const body = [ + '* feat: real thing by @a in https://github.com/strands-agents/harness-sdk/pull/1', + '', + '## New Contributors', + '* @newdev made their first contribution in https://github.com/strands-agents/harness-sdk/pull/2700', + ].join('\n') + const r = await buildReleaseFile('strands-agents/harness-sdk', + { tag_name: 'python/v1.43.0', published_at: '2026-06-12T00:00:00Z', html_url: 'h', body }, + { enrich: async () => ({ areas: [], breaking: false, commit: null, author: null, languages: ['python'] }), readExisting: async () => null }) + assert.match(r.contents, /newContributors:\n - \{ login: newdev, pr: 2700 \}/) + assert.doesNotMatch(r.contents, /first contribution/) // not an entry +}) + +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/) +}) diff --git a/changelog-release-pr/scripts/enrich.cjs b/changelog-release-pr/scripts/enrich.cjs new file mode 100644 index 0000000..53cb5c0 --- /dev/null +++ b/changelog-release-pr/scripts/enrich.cjs @@ -0,0 +1,52 @@ +// Enrich a parsed line from its linked PR: area-* labels, breaking flag, +// 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, and with languages unknown. + +/** + * @typedef {{labels:string[], merge_commit_sha:string|null, user:string|null, files?:string[]}} PrData + * @typedef {(repo:string, num:number)=>Promise} 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, 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, 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, languages: languagesFromFiles(pr.files) } +} + +module.exports = { enrichFromPr, languagesFromFiles } diff --git a/changelog-release-pr/scripts/enrich.test.cjs b/changelog-release-pr/scripts/enrich.test.cjs new file mode 100644 index 0000000..d808467 --- /dev/null +++ b/changelog-release-pr/scripts/enrich.test.cjs @@ -0,0 +1,63 @@ +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, languages: 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) +}) + +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/parse-release-body.cjs b/changelog-release-pr/scripts/parse-release-body.cjs new file mode 100644 index 0000000..5547a1e --- /dev/null +++ b/changelog-release-pr/scripts/parse-release-body.cjs @@ -0,0 +1,89 @@ +// 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>" +// Author logins may carry a bracket suffix for apps/bots, e.g. dependabot[bot]. +const TAIL = /^(.*?)(?:\s+by\s+@([\w-]+(?:\[[\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']) +// GitHub's "## New Contributors" lines: "@login made their first contribution in <pr-url>". +// These are celebrated separately (parseNewContributors) and must NOT become entries. +// Login pattern must mirror TAIL's (incl. the bracket suffix for bots like dependabot[bot]). +const FIRST_CONTRIBUTION = /^@([\w-]+(?:\[[\w-]+\])?) made their first contribution\s+in\s+https?:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)\s*$/i + +/** + * @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 + if (FIRST_CONTRIBUTION.test(li[1].trim())) continue // celebrated separately + 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]) && !FIRST_CONTRIBUTION.test(li[1].trim())) n++ + } + return n +} + +/** + * Extract GitHub's "## New Contributors" section into structured data. + * prRepo is the repo from the PR url (may differ from the release's repo for + * pre-monorepo releases) — gate/enrich against THAT repo, mirroring entries. + * @param {string|null|undefined} body + * @returns {Array<{login:string, pr:number, prRepo:string}>} + */ +function parseNewContributors(body) { + if (!body) return [] + const out = [] + for (const raw of body.replace(/\r\n?/g, '\n').split('\n')) { + const li = raw.match(LINE) + if (!li) continue + const m = li[1].trim().match(FIRST_CONTRIBUTION) + if (m) out.push({ login: m[1], pr: Number(m[3]), prRepo: m[2] }) + } + return out +} + +module.exports = { parseReleaseBody, countChangelogBullets, parseNewContributors } 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..7d4e9b4 --- /dev/null +++ b/changelog-release-pr/scripts/parse-release-body.test.cjs @@ -0,0 +1,110 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const { parseReleaseBody, countChangelogBullets, parseNewContributors } = 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', () => { + // Whole-shape equality: the parser is deterministic, so assert everything. + const repo = 'strands-agents/sdk-python' + assert.deepEqual(parseReleaseBody(body), [ + { type: 'fix', scope: 'tests', breaking: false, title: 'fix flaky tests', author: 'lizradway', pr: 2319, prRepo: repo }, + { type: 'feat', scope: 'gemini', breaking: false, title: 'plumb through cache tokens', author: 'yatszhash', pr: 2287, prRepo: repo }, + { type: 'feat', scope: null, breaking: true, title: 'drop legacy run()', author: 'pgrayy', pr: 2299, prRepo: repo }, + { type: 'docs', scope: null, breaking: false, title: 'tidy readme', author: 'someone', pr: 2300, prRepo: repo }, + { type: 'chore', scope: null, breaking: false, title: 'bump deps', author: null, pr: 2301, prRepo: repo }, + ]) +}) + +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('bot authors with bracket suffix parse cleanly (no title pollution)', () => { + const lines = parseReleaseBody( + '* chore(deps): bump uuid from 10.0.0 to 13.0.0 by @dependabot[bot] in https://github.com/o/r/pull/625' + ) + assert.equal(lines.length, 1) + assert.equal(lines[0].title, 'bump uuid from 10.0.0 to 13.0.0') + assert.equal(lines[0].author, 'dependabot[bot]') + assert.equal(lines[0].pr, 625) +}) + +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('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) +}) + +const nc = `## What's Changed +* feat: real change by @dev in https://github.com/o/r/pull/1 + +## New Contributors +* @senthilkumarmohan made their first contribution in https://github.com/strands-agents/harness-sdk/pull/2623 +* @ianholtz made their first contribution in https://github.com/strands-agents/harness-sdk/pull/2651 + +**Full Changelog**: https://github.com/o/r/compare/a...b` + +test('parseNewContributors extracts structured logins + prs + prRepo', () => { + assert.deepEqual(parseNewContributors(nc), [ + { login: 'senthilkumarmohan', pr: 2623, prRepo: 'strands-agents/harness-sdk' }, + { login: 'ianholtz', pr: 2651, prRepo: 'strands-agents/harness-sdk' }, + ]) + assert.deepEqual(parseNewContributors(''), []) + assert.deepEqual(parseNewContributors(null), []) +}) + +test('bracket-suffixed bot first-contribution lines are captured, not leaked into entries', () => { + const body = '* @dependabot[bot] made their first contribution in https://github.com/o/r/pull/625' + assert.deepEqual(parseNewContributors(body), [{ login: 'dependabot[bot]', pr: 625, prRepo: 'o/r' }]) + assert.deepEqual(parseReleaseBody(body), []) // must NOT become an entry +}) + +test('first-contribution lines are excluded from entries', () => { + const lines = parseReleaseBody(nc) + assert.equal(lines.length, 1) + assert.equal(lines[0].title, 'real change') +}) + +test('countChangelogBullets ignores first-contribution lines (no false drift)', () => { + assert.equal(countChangelogBullets(nc), 1) +}) + +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) +}) diff --git a/changelog-release-pr/scripts/render-markdown.cjs b/changelog-release-pr/scripts/render-markdown.cjs new file mode 100644 index 0000000..11ff016 --- /dev/null +++ b/changelog-release-pr/scripts/render-markdown.cjs @@ -0,0 +1,95 @@ +// 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, '\\"') + .replace(/\t/g, '\\t') + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n')}"` +} + +// 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) && !YAML_RESERVED.has(v.toLowerCase())) 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.map(scalar).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: ${scalar(f.tag)}`) + lines.push(`date: ${f.date}`) + lines.push(`releaseUrl: ${f.releaseUrl}`) + lines.push(`packageUrl: ${f.packageUrl}`) + 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: []') + } + if (f.newContributors && f.newContributors.length) { + lines.push('newContributors:') + for (const c of f.newContributors) { + lines.push(` - { login: ${scalar(c.login)}, pr: ${c.pr} }`) + } + } + 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..ebaba11 --- /dev/null +++ b/changelog-release-pr/scripts/render-markdown.test.cjs @@ -0,0 +1,121 @@ +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('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('renders newContributors when present, omits when empty', () => { + const md = renderMarkdown({ ...file, newContributors: [{ login: 'newdev', pr: 2700 }, { login: 'other-dev', pr: 2701 }] }) + assert.match(md, /newContributors:\n - \{ login: newdev, pr: 2700 \}\n - \{ login: other-dev, pr: 2701 \}/) + assert.doesNotMatch(renderMarkdown(file), /newContributors/) +}) + +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)) +}) diff --git a/changelog-release-pr/scripts/run-action.cjs b/changelog-release-pr/scripts/run-action.cjs new file mode 100644 index 0000000..4b7324d --- /dev/null +++ b/changelog-release-pr/scripts/run-action.cjs @@ -0,0 +1,108 @@ +// 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 skipExisting = process.env.SKIP_EXISTING === 'true' + + if (mode === 'single' && !tag) { + core.setFailed('changelog: single mode requires a tag (got none). Pass the release tag or use mode: backfill.') + return { written: [], warnings: [] } + } + + 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 + 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 + // best-effort; the next sync re-enriches. + if (e.status !== 404) core.warning(`PR ${repoFull}#${num}: ${e.status || e.message} — skipping enrichment`) + return null + } + }, + } + + const result = await run({ + repo: sourceRepo, + mode, + tag, + skipExisting, + 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 + a git-safe branch name (tags contain '/' and '.'). + // Slug from the repo NAME so different source repos (harness-sdk vs the + // archived sdk-typescript) can never collide on the same backfill branch. + const repoSlug = sourceRepo.split('/')[1] || sourceRepo + 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-${repoSlug}-${tagSlug}`) + return result +} + +module.exports = runAction diff --git a/changelog-release-pr/scripts/run.cjs b/changelog-release-pr/scripts/run.cjs new file mode 100644 index 0000000..1b01501 --- /dev/null +++ b/changelog-release-pr/scripts/run.cjs @@ -0,0 +1,63 @@ +// 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, + * 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>, + * }} opts + * @returns {Promise<{written:string[], warnings:string[]}>} + */ +async function run(opts) { + const warnings = [] + 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] : [] + if (!r) warnings.push(`${opts.repo}: no release found for tag "${opts.tag || ''}" — nothing to sync.`) + } + + // Skip drafts (no published_at) and prereleases — the changelog covers + // published, stable releases only. + releases = releases.filter((r) => r && r.published_at && !r.prerelease) + + // Memoize PR fetches: a first-time contributor's PR usually also appears in + // "What's Changed", and on the monorepo each fetch includes a paginated file + // list — caching roughly halves API spend on a backfill. + const prCache = new Map() + const getPr = (repo, num) => { + const key = `${repo}#${num}` + if (!prCache.has(key)) prCache.set(key, opts.client.getPr(repo, num)) + return prCache.get(key) + } + + const deps = { + enrich: (prRepo, pr) => enrichFromPr(prRepo, pr, getPr), + readExisting: opts.readExisting, + skipExisting: opts.skipExisting === true, + } + + const written = [] + 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..7e9f002 --- /dev/null +++ b/changelog-release-pr/scripts/run.test.cjs @@ -0,0 +1,122 @@ +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']) + // 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({ + 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 and warns', 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, []) + assert.match(res.warnings[0], /no release found for tag "python\/v9\.9\.9"/) +}) + +test('prereleases are skipped', async () => { + const pre = [{ tag_name: 'v2.0.0', published_at: '2026-06-01T00:00:00Z', html_url: 'h', prerelease: true, body: '* feat: x by @a in https://github.com/o/r/pull/1\n' }] + const client = { listReleases: async () => pre, getRelease: async () => null, getPr: async () => null } + const res = await run({ + repo: 'strands-agents/evals', mode: 'backfill', client, + readExisting: async () => null, writeFile: async () => { throw new Error('must not write') }, + }) + assert.deepEqual(res.written, []) +}) + +test('memoizes PR fetches across entries and newContributors', async () => { + let prCalls = 0 + const rel = [{ + tag_name: 'python/v9.0.0', published_at: '2026-06-01T00:00:00Z', html_url: 'h', + body: [ + '* feat: thing by @newdev in https://github.com/strands-agents/harness-sdk/pull/7', + '', + '## New Contributors', + '* @newdev made their first contribution in https://github.com/strands-agents/harness-sdk/pull/7', + ].join('\n'), + }] + const client = { + listReleases: async () => rel, getRelease: async () => null, + getPr: async () => { prCalls++; return { labels: [], merge_commit_sha: 'abc1234', user: 'newdev', files: ['strands-py/x.py'] } }, + } + await run({ repo: 'strands-agents/harness-sdk', mode: 'backfill', client, readExisting: async () => null, writeFile: async () => {} }) + assert.equal(prCalls, 1) // entry + contributor gating share one fetch +}) + +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 } + 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/) +}) diff --git a/changelog-release-pr/scripts/tag-meta.cjs b/changelog-release-pr/scripts/tag-meta.cjs new file mode 100644 index 0000000..9b7f196 --- /dev/null +++ b/changelog-release-pr/scripts/tag-meta.cjs @@ -0,0 +1,56 @@ +// 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]) } + } + // The archived pre-monorepo TypeScript repo: all its releases are + // harness/typescript history (used only by the one-time backfill). + if (repo.endsWith('/sdk-typescript')) { + if (!/^v\.?\d/.test(tag)) return null + return { sdk: 'harness', language: 'typescript', version: cleanVersion(tag) } + } + // 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..1359848 --- /dev/null +++ b/changelog-release-pr/scripts/tag-meta.test.cjs @@ -0,0 +1,59 @@ +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('archived sdk-typescript repo bare v tags map to harness/typescript', () => { + assert.deepEqual(tagToMeta('strands-agents/sdk-typescript', 'v1.3.0'), { + sdk: 'harness', language: 'typescript', version: '1.3.0', + }) + // rc tags parse too + assert.deepEqual(tagToMeta('strands-agents/sdk-typescript', 'v1.0.0-rc.5'), { + sdk: 'harness', language: 'typescript', version: '1.0.0-rc.5', + }) +}) + +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/') +})