Skip to content
Merged
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
10 changes: 5 additions & 5 deletions site/scripts/changelog/build-release-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ export async function buildReleaseFile(
// preserved as curated narrative via mergePreserving below).
const { entries: parsed, warning } = await deps.deriveEntries(repo, release)

// Entry gates: docs-only PRs drop on every stream; monorepo streams also
// drop a PR that touches ONLY the other language's dir. Only a POSITIVE dir
// signal gates -- empty/unknown languages are kept (pre-monorepo PRs have no
// strands-py/strands-ts dirs; gating on empty would wrongly empty those
// releases).
// Entry gates: docs-only PRs drop on every stream; monorepo streams also drop
// a PR whose paths point ONLY at the other language (see languagesFromFiles).
// Only a POSITIVE language signal gates -- empty/unknown languages are kept
// (pre-monorepo PRs have no strands-py/strands-ts dirs; gating on empty would
// wrongly empty those releases).
const isMonorepoStream =
meta.sdk === 'harness' && (release.tag_name.startsWith('python/') || release.tag_name.startsWith('typescript/'))

Expand Down
31 changes: 30 additions & 1 deletion site/scripts/changelog/enrich.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ const LANGUAGE_DIRS: Record<string, string> = {
'strands-ts': 'typescript',
}

// The repo-root npm lockfile resolves the strands-ts workspace (root
// package.json `workspaces`), so a bump there can change what npm consumers
// install and can never reach the PyPI distribution. A Map keyed by filename,
// so a GitHub-controlled path cannot reach Object.prototype. Lockfiles only:
// the sibling manifests (package.json, pyproject.toml) also carry repo-wide
// tooling, which belongs to neither SDK.
const ROOT_LOCKFILES = new Map([['package-lock.json', 'typescript']])

// Top-level dirs holding docs/blog/website content rather than SDK code.
// `site/` is the monorepo home for all docs+blog+website; `docs/` is the
// pre-monorepo / evals docs tree. A PR confined to these never lines up with
Expand Down Expand Up @@ -50,6 +58,10 @@ function isDocPath(f: string): boolean {
* 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)
*
* A PR confined to repo-root lockfiles touches no SDK dir yet still changes one
* SDK's dependency tree, so it's attributed to that ecosystem's language. A dir
* hit wins outright: a lockfile touched alongside SDK code adds no language.
*/
function languagesFromFiles(files: unknown): string[] | null {
if (!Array.isArray(files)) return null
Expand All @@ -58,7 +70,24 @@ function languagesFromFiles(files: unknown): string[] | null {
const top = String(f).split('/')[0]
if (LANGUAGE_DIRS[top]) langs.add(LANGUAGE_DIRS[top])
}
return [...langs]
if (langs.size > 0) return [...langs]
return rootLockfileLanguages(files) || []
}

/**
* Languages implied by a PR that changes *nothing but* repo-root lockfiles, or
* null otherwise. Requiring every file to be a root lockfile keeps a PR that
* edits repo tooling alongside a lockfile language-neutral, and a nested
* lockfile (site/, .github/) is already covered by its dir.
*/
function rootLockfileLanguages(files: unknown[]): string[] | null {
const langs = new Set<string>()
for (const f of files) {
const lang = ROOT_LOCKFILES.get(String(f))
if (!lang) return null
langs.add(lang)
}
return langs.size > 0 ? [...langs] : null
}

/**
Expand Down
33 changes: 33 additions & 0 deletions site/test/changelog/build-release-file.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import { buildReleaseFile } from '../../scripts/changelog/build-release-file'
import { classifyTitle } from '../../scripts/changelog/parse-release-body'
import { enrichFromPr } from '../../scripts/changelog/enrich'
import type { BuildDeps } from '../../scripts/changelog/build-release-file'

// buildReleaseFile sources entries from deps.deriveEntries (compare-driven), not
Expand Down Expand Up @@ -165,6 +166,38 @@ describe('build-release-file', () => {
expect(ts!.contents).not.toMatch(/site thing/)
})

it('a root-lockfile-only dependency bump lands on one stream only', async () => {
// Root lockfile bumps touch no SDK dir, but they do change one published
// package's dependency tree, so exactly one stream may carry them (#3712).
const body =
'* ci(typescript): bump hono from 4.12.32 to 4.13.0 by @dependabot in https://github.com/strands-agents/harness-sdk/pull/3643'
const deps = {
// Real enricher: the language signal under test is derived from PR files.
enrich: (repo: string, pr: number) =>
enrichFromPr(repo, pr, async () => ({
labels: [],
merge_commit_sha: 'cfc8825aaaa',
user: 'dependabot[bot]',
files: ['package-lock.json'],
})),
deriveEntries: bodyDerive,
readExisting: async () => null,
}
const py = await buildReleaseFile(
'strands-agents/harness-sdk',
{ tag_name: 'python/v1.51.0', published_at: '2026-08-07T00:00:00Z', html_url: 'h', body },
deps as any
)
expect(py!.contents).not.toMatch(/bump hono/)

const ts = await buildReleaseFile(
'strands-agents/harness-sdk',
{ tag_name: 'typescript/v1.12.0', published_at: '2026-08-07T00:00:00Z', html_url: 'h', body },
deps as any
)
expect(ts!.contents).toMatch(/bump hono/)
})

it('monorepo-tagged release with PRs in the OLD flat repo is not language-gated', async () => {
// Early python releases were re-tagged `python/v*` but their PRs live in the
// old `sdk-python` repo (code under `src/`, no strands-py/ dir). The file
Expand Down
54 changes: 54 additions & 0 deletions site/test/changelog/enrich.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,58 @@ describe('enrich', () => {
const e = await enrichFromPr('r', 1, f)
expect(e.languages).toBe(null)
})

it('a root-npm-lockfile-only PR is attributed to typescript', async () => {
// Guards the leak in the python/v1.51.0 changelog sync (#3712): five npm bumps
// that touch only the root lockfile were listed on the python stream.
const npmBump = async () => ({
labels: [],
merge_commit_sha: 'abc1234',
user: 'dependabot[bot]',
files: ['package-lock.json'],
})
expect((await enrichFromPr('r', 1, npmBump)).languages).toEqual(['typescript'])
})

it('a root lockfile alongside SDK code keeps the dir signal', async () => {
const f = async () => ({
labels: [],
merge_commit_sha: 'abc1234',
user: 'x',
files: ['package-lock.json', 'strands-py/pyproject.toml'],
})
const e = await enrichFromPr('r', 1, f)
expect(e.languages).toEqual(['python'])
})

it('root manifests and non-root lockfiles stay language-neutral', async () => {
// package.json/pyproject.toml configure repo-wide tooling, and a nested
// lockfile belongs to its own dir (site = docs, .github = ci).
const files = [
'package.json',
'pyproject.toml',
'site/package-lock.json',
'.github/scripts/pr-metrics/tools/package-lock.json',
]
for (const file of files) {
const e = await enrichFromPr('r', 1, async () => ({
labels: [],
merge_commit_sha: 'abc1234',
user: 'x',
files: [file],
}))
expect(e.languages, file).toEqual([])
}
})

it('a lockfile bump carrying any other file stays language-neutral', async () => {
const f = async () => ({
labels: [],
merge_commit_sha: 'abc1234',
user: 'x',
files: ['package-lock.json', '.github/workflows/ci.yml'],
})
const e = await enrichFromPr('r', 1, f)
expect(e.languages).toEqual([])
})
})
Loading