Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5a89d65
feat(changelog): map release tags to sdk/language/version
yonib05 Jun 11, 2026
92c1812
feat(changelog): parse release body into structured lines with drift …
yonib05 Jun 11, 2026
8f1384d
feat(changelog): enrich entries from linked PR labels and commit
yonib05 Jun 11, 2026
51a1d13
feat(changelog): render release markdown preserving curated highlights
yonib05 Jun 11, 2026
6990dec
feat(changelog): orchestrate a release into a rendered changelog file
yonib05 Jun 11, 2026
df575fa
feat(changelog): select releases and write changelog files
yonib05 Jun 11, 2026
208e9d1
feat(changelog): composite action — generate and open PR
yonib05 Jun 11, 2026
696aeac
fix(changelog): normalize CRLF line endings in release body parsing
yonib05 Jun 11, 2026
8c8bd48
fix(changelog): quote YAML-significant and reserved-word values in re…
yonib05 Jun 11, 2026
e68f90a
fix(changelog): sanitize PR branch name from tag, harden backfill aga…
yonib05 Jun 11, 2026
dc3ba44
fix(changelog): skip draft releases (null published_at) in backfill
yonib05 Jun 12, 2026
3f2ca48
feat(changelog): add skip-existing mode for cheap non-regressing cron…
yonib05 Jun 12, 2026
c1d0d35
feat(changelog): gate monorepo entries by SDK language from PR change…
yonib05 Jun 12, 2026
0c6bb59
feat(changelog): extract New Contributors into structured frontmatter
yonib05 Jun 12, 2026
dbb10a4
feat(changelog): language-gate new contributors, keep docs/ci-only on…
yonib05 Jun 12, 2026
40045ef
feat(changelog): map archived sdk-typescript releases for one-time ba…
yonib05 Jun 12, 2026
74af740
fix(changelog): parse bot authors with bracket suffix (dependabot[bot])
yonib05 Jun 12, 2026
edb1cda
fix(changelog): address review — bot first-contribution parsing, prRe…
yonib05 Jun 12, 2026
48c01c8
refactor(changelog): drop unused compareUrl render branch
yonib05 Jun 12, 2026
f0c9f1d
refactor(changelog): drop unused languagesFromFiles export (internal-…
yonib05 Jun 14, 2026
57f6812
feat(changelog): filter docs-only PRs and fix cross-repo language gating
yonib05 Jun 16, 2026
34fde62
docs(changelog): add curated-notes authoring guide; cover contributor…
yonib05 Jun 17, 2026
af13a64
feat(changelog): parse short "in #N" pr refs and strip shared-SDK ann…
yonib05 Jun 17, 2026
c982f52
fix(changelog): honor action repo on checkout; treat root docs as doc…
yonib05 Jun 17, 2026
24e2270
refactor(changelog): drop unused written_count step output
yonib05 Jun 17, 2026
9f1172a
feat(changelog): derive entries from the compare API, not the release…
yonib05 Jun 18, 2026
f936d5b
fix(changelog): paginate compare results; gate only on positive dir s…
yonib05 Jun 18, 2026
1c7239f
refactor(changelog): drop body-parsing dead code now that entries com…
yonib05 Jun 18, 2026
ca86fb8
refactor(changelog): dedupe stream gating into dropFromStream
yonib05 Jun 18, 2026
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
131 changes: 131 additions & 0 deletions changelog-release-pr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# 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. Derive structured entries from the GitHub **compare API**: list every merged
commit between the prior tag in the same stream and this release, resolve
each to its PR (commit→PR API), and classify the PR title
(conventional-commit type/scope). This is independent of how the release
notes are written, so it doesn't break when the body format drifts. The
release body is NOT parsed for entries; "New Contributors" lines are still
extracted from it 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/<sdk>/<file>.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.

## Re-syncing existing files

The daily cron runs with `skip-existing: true`, so it only writes files that
don't exist yet and never touches committed ones. A full refresh
(`skip-existing: false`, or a `backfill` dispatch) re-renders **every** release
through the current renderer. The renderer emits the canonical, fully-expanded
entry shape (every field present: `breaking`, `commit`, `commitUrl`, …), so the
first full refresh after any hand-edited or terser committed files will produce
a large reformat-only diff — frontmatter is rewritten to canonical form even
where nothing changed semantically. Human-authored `highlights:` and markdown
bodies are preserved (see below); only the generated frontmatter reformats.
Expect and skim that churn; it's cosmetic.

## 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.

## Authoring curated (narrative) release notes

The generated file gives every release a structured summary (entries grouped
into Features / Fixes / Other, area tags, first-time-contributor chips). For
high-visibility releases you can add a hand-written narrative on top — prose,
code samples, migration notes — that renders on the release's detail page and,
namespaced, on the combined changelog. These are the best customer-facing
changelogs; write them when there's manpower to.

Two curated fields, both survive re-syncs (the parser never overwrites them):

- **`highlights:`** — a short YAML string (1–3 sentences, inline markdown OK)
shown in an accent callout at the top of the release. Use for a quick "why
this release matters."
- **markdown body** — everything after the closing `---`. Long-form narrative.

### Conventions (so curated notes stay consistent and valid)

1. **Don't restate the structured data.** The frontmatter already renders the
per-PR entry list and the first-time-contributor chips. A curated body must
NOT include GitHub's auto-generated `## What's Changed` /
`## New Contributors` / `**Full Changelog**` scaffolding — that duplicates
the chips/entries and (because every release reuses those headings) produced
colliding heading ids on the combined page. Keep only the human narrative.
2. **Start body headings at `###`.** The version number is the page heading
(`h1` on the detail page); section headings inside the body should be `###`
or deeper so the outline stays well-formed.
3. **Lead with the "why."** Describe what changed and why a user cares, then
show a minimal code sample. Link PRs inline as `[PR#1234](url)`.
4. **Set `areas`/types via PR labels**, not prose — the structured summary is
generated from `area-*` labels and conventional-commit titles.

### Template

Copy this skeleton into a release file's body (after the frontmatter). The
frontmatter itself is generated; you add `highlights:` and the body.

```markdown
---
# ...generated frontmatter (sdk, language, version, tag, date, urls, entries,
# newContributors)...
highlights: Adds X and Y; migrates Z. One or two sentences on why it matters.
---

### Headline feature — [PR#1234](https://github.com/strands-agents/harness-sdk/pull/1234)

One or two paragraphs on what it does and why, in plain language.

```python
# a minimal, runnable example of the new capability
```

### Another notable change — [PR#1240](https://github.com/strands-agents/harness-sdk/pull/1240)

Short narrative. Note any migration steps or breaking behavior here.

### Notes

- Smaller call-outs, deprecations, or upgrade guidance as a short list.
```

The six files under `site/src/content/changelog/` that carry hand-written
bodies today (`harness/python-v1.25.0`, `harness/python-v1.35.0`,
`harness/typescript-v0.2.1`, `harness/typescript-v1.0.0-rc.3`, `evals/v0.1.5`,
`evals/v0.1.14`) follow this convention and serve as worked examples.
84 changes: 84 additions & 0 deletions changelog-release-pr/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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:
# Track BOTH the repo and ref the caller pinned this action to, so
# `uses: <owner>/devtools/changelog-release-pr@<sha>` pins the scripts to
# that same owner+sha — including forks/mirrors. Hard-coding the repo (or
# ref) here would let script behavior float regardless of the caller's
# pin, and would break a fork whose ref doesn't exist upstream.
repository: ${{ github.action_repository }}
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
123 changes: 123 additions & 0 deletions changelog-release-pr/scripts/build-release-file.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// 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 { 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 {{deriveEntries:(repo:string,release:object)=>Promise<{entries:Array,warning?:string}>, enrich:(prRepo:string,pr:number)=>Promise<{areas:string[],breaking:boolean,commit:string|null,author:string|null,languages:string[]|null,docsOnly:boolean}>, 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

// Entries come from the GitHub compare API (every merged PR between the prior
// tag and this one) — deterministic and independent of release-note format.
// The release body is NOT parsed for entries; it's preserved as curated
// narrative via mergePreserving below.
const { entries: parsed, warning } = await deps.deriveEntries(repo, release)

// Two gates apply to every entry:
//
// 1. Docs-only (ALL streams): a PR confined to docs/blog/website dirs never
// lines up with an SDK+language, so it's dropped everywhere — including
// pre-monorepo bare-`v` and evals, which are otherwise unfiltered. This
// keeps the changelog focused on SDK+language work (a blog-only PR or a
// pure docs change won't appear in any stream).
// 2. Language (monorepo prefixed tags only): those releases list every merged
// PR regardless of language, so gate by which SDK dirs the PR touched —
// python stream keeps python-touching PRs, ts keeps ts-touching, both →
// both. Unknown file info → kept (degrade open).
//
// CRUCIAL: only gate when the PR has a POSITIVE dir signal — i.e. it
// touches strands-py/ and/or strands-ts/. A PR with EMPTY languages
// (touches neither: root config/CI, or a pre-monorepo flat-layout PR whose
// code lived under src/ before the strands-py/ dir existed) must be KEPT,
// not dropped. Gating on empty languages would wrongly empty pre-monorepo
// releases whose tags were re-applied as python/v* in the monorepo.
// Pre-monorepo bare-`v` and evals are single-language: no language gate.
const isMonorepoStream =
meta.sdk === 'harness' &&
(release.tag_name.startsWith('python/') || release.tag_name.startsWith('typescript/'))

// Shared keep/drop decision for both entries and new contributors: drop a
// docs-only PR from every stream, and on a monorepo stream drop a PR with a
// POSITIVE dir signal for the OTHER language (empty/unknown languages are
// kept — see the language-gate note above).
const dropFromStream = (enr) =>
enr.docsOnly ||
(isMonorepoStream && Array.isArray(enr.languages) && enr.languages.length > 0 && !enr.languages.includes(meta.language))

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, docsOnly: false }
if (dropFromStream(enr)) 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 use the same keep/drop rule as entries (dropFromStream):
// a docs-only first PR doesn't belong in an SDK+language changelog, and a
// monorepo PR with a positive other-language signal is gated out — but a
// first PR touching no sdk dir (e.g. ci) or with unknown files is kept in
// both streams: people aren't noise.
const rawContributors = parseNewContributors(release.body)
const newContributors = []
for (const c of rawContributors) {
// 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)
if (dropFromStream(enr)) continue
newContributors.push(c)
}

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 }
Loading