mirror of
https://github.com/strands-agents/harness-sdk.git
synced 2026-10-02 02:44:48 +08:00
fix(cli): depend on the published harness in exported projects (#4478)
Co-authored-by: Mackenzie Zastrow <zastrowm@users.noreply.github.com>
This commit is contained in:
co-authored by
Mackenzie Zastrow
parent
4d376d8e8f
commit
8ecf1ebc18
@@ -3,9 +3,9 @@ name: "Harness CLI: Test Package Pack"
|
||||
# End-to-end package install smoke test for the Strands CLI (`@strands-agents/cli`).
|
||||
# Reusable; called by release-harness-cli.yml. Reproduces a real user's install:
|
||||
# `npm pack` the CLI, install the tarball in a tempdir OUTSIDE the repo tree (so
|
||||
# nothing hoists), run the `strands` bin, and confirm the bundled harness-py is
|
||||
# present. The CLI's @strands-agents/harness / @strands-agents/sdk deps resolve
|
||||
# from the registry (ranged, not pinned), so only the CLI tarball is packed.
|
||||
# nothing hoists), and run the `strands` bin. The CLI's @strands-agents/harness /
|
||||
# @strands-agents/sdk deps resolve from the registry (ranged, not pinned), so only
|
||||
# the CLI tarball is packed.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -57,9 +57,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Pack, install in a tmpdir, run the bin
|
||||
# npm pack runs prepack (clean + build = tsc + bundle-python.js), so the
|
||||
# tarball carries the bundled harness-py under dist/python. The tmpdir
|
||||
# MUST live outside the monorepo so nothing hoists into resolution.
|
||||
# The tmpdir MUST live outside the monorepo so nothing hoists into resolution.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARBALL=$(npm pack --silent)
|
||||
@@ -73,17 +71,6 @@ jobs:
|
||||
npm install --ignore-scripts --no-audit --no-fund "$TARBALL_PATH"
|
||||
./node_modules/.bin/strands --help
|
||||
|
||||
- name: Assert the tarball bundles harness-py
|
||||
# `strands --help` never touches the Python sidecar, so a silent
|
||||
# bundle-python.js failure would pass the smoke test above.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
tgzs=(./*.tgz)
|
||||
tar tzf "${tgzs[0]}" > contents.txt
|
||||
grep -q '^package/dist/python/pyproject.toml$' contents.txt \
|
||||
|| { echo "::error::CLI tarball has no bundled Python (dist/python missing)."; exit 1; }
|
||||
|
||||
- name: Upload tarball for downstream inspect/publish
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
|
||||
@@ -9,8 +9,8 @@ name: "Harness CLI: Release"
|
||||
#
|
||||
# The CLI depends on @strands-agents/harness and @strands-agents/sdk as version
|
||||
# RANGES (< 2.0.0 / < 1.18.0), resolved from the registry at install time -- so
|
||||
# there is no exact-pin or check-library-published gate. The tarball bundles the
|
||||
# harness-py source (scripts/bundle-python.js), so no PyPI dependency either.
|
||||
# there is no exact-pin or check-library-published gate. Exported projects pull
|
||||
# the harness from npm / PyPI too, so the tarball carries no vendored library.
|
||||
#
|
||||
# One-time setup before the first non-dry run:
|
||||
# - GitHub environments `release-gate` and `npm` (already used by other releases).
|
||||
@@ -243,9 +243,7 @@ jobs:
|
||||
echo "::error::${tgzs[0]}: is $name@$version, expected $EXPECTED_NAME@$EXPECTED_VERSION."
|
||||
exit 1
|
||||
fi
|
||||
tar tzf "${tgzs[0]}" | grep -q '^package/dist/python/pyproject.toml$' \
|
||||
|| { echo "::error::${tgzs[0]}: missing bundled harness-py (dist/python)."; exit 1; }
|
||||
echo "${tgzs[0]} is $name@$version and bundles harness-py"
|
||||
echo "${tgzs[0]} is $name@$version"
|
||||
|
||||
- name: Upload verified tarball for publish
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"NOTICE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc --project src/tsconfig.json && node scripts/bundle-python.js",
|
||||
"build": "tsc --project src/tsconfig.json && node scripts/copy-python-runtime.js",
|
||||
"clean:dist": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
||||
"prepack": "npm run clean:dist && npm run build",
|
||||
"clean": "rm -rf dist node_modules",
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { cpSync, mkdirSync, rmSync } from 'node:fs'
|
||||
import { basename, join } from 'node:path'
|
||||
import { URL, fileURLToPath } from 'node:url'
|
||||
|
||||
const source = fileURLToPath(new URL('../../harness-py', import.meta.url))
|
||||
const destination = fileURLToPath(new URL('../dist/python', import.meta.url))
|
||||
|
||||
rmSync(destination, { recursive: true, force: true })
|
||||
mkdirSync(destination, { recursive: true })
|
||||
for (const name of ['pyproject.toml', 'README.md', 'LICENSE', 'NOTICE']) {
|
||||
cpSync(join(source, name), join(destination, name))
|
||||
}
|
||||
cpSync(join(source, 'src'), join(destination, 'src'), {
|
||||
recursive: true,
|
||||
filter: (path) => basename(path) !== '__pycache__',
|
||||
})
|
||||
const worker = fileURLToPath(new URL('../src/tui/project/worker.py', import.meta.url))
|
||||
const workerDestination = fileURLToPath(new URL('../dist/src/tui/project/worker.py', import.meta.url))
|
||||
cpSync(worker, workerDestination)
|
||||
@@ -0,0 +1,9 @@
|
||||
import { cpSync } from 'node:fs'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// tsc emits only the .ts modules, so the CLI's own Python runtime file has to be copied next to its
|
||||
// compiled sibling by hand. python.ts loads it as `new URL('./worker.py', import.meta.url)`, i.e.
|
||||
// dist/src/tui/project/worker.py. (sidecar.py ships via package.json `files` with a src fallback.)
|
||||
const worker = fileURLToPath(new URL('../src/tui/project/worker.py', import.meta.url))
|
||||
const destination = fileURLToPath(new URL('../dist/src/tui/project/worker.py', import.meta.url))
|
||||
cpSync(worker, destination)
|
||||
@@ -19,7 +19,6 @@ import { portableConfig, validateNoConfigSecrets } from './configuration.js'
|
||||
import { containsPath, type PackagedSource } from './packaging.js'
|
||||
|
||||
const MAX_PROJECT_BYTES = 50 * 1024 * 1024
|
||||
const VENDOR_PATH = 'vendor/strands-harness'
|
||||
|
||||
interface SkillPackage {
|
||||
id: string
|
||||
@@ -73,11 +72,6 @@ export async function writeAgentProject(
|
||||
addBytes(buffer.length)
|
||||
zip.addBuffer(buffer, path, { compress: false, mode: 0o100644 })
|
||||
}
|
||||
if (language === 'typescript') {
|
||||
addTypeScriptPackage(zip, addBytes)
|
||||
} else {
|
||||
addDirectory(zip, pythonPackageRoot(), VENDOR_PATH, addBytes)
|
||||
}
|
||||
for (const skill of skills) {
|
||||
addPath(zip, skill.path, `agent/skills/${skill.id}`, addBytes, privatePaths)
|
||||
}
|
||||
@@ -259,7 +253,7 @@ function typescriptProject(config: HarnessAgentConfig): Record<string, string> {
|
||||
check: 'tsc --noEmit',
|
||||
},
|
||||
dependencies: {
|
||||
'@strands-agents/harness': `file:${VENDOR_PATH}`,
|
||||
'@strands-agents/harness': `^${harnessVersion()}`,
|
||||
...typescriptProviderDependencies(config),
|
||||
...config.dependencies.typescript,
|
||||
},
|
||||
@@ -315,7 +309,7 @@ function pythonProject(config: HarnessAgentConfig): Record<string, string> {
|
||||
}
|
||||
: {}),
|
||||
'requirements.txt': [
|
||||
`./${VENDOR_PATH}${pythonProviderExtras(config)}`,
|
||||
`strands-harness${pythonProviderExtras(config)}${harnessPythonSpecifier()}`,
|
||||
...(hasCedarPolicy(config) ? ['strands-agents[cedar]'] : []),
|
||||
...config.dependencies.python,
|
||||
'',
|
||||
@@ -416,7 +410,7 @@ function projectReadme(config: HarnessAgentConfig, language: AgentProjectLanguag
|
||||
`Edit \`agent/${python ? 'agent.py' : 'agent.ts'}\` to change the agent. The CLI imports the agent defined there.`,
|
||||
'Each new launch imports the code again; there is no separate configuration snapshot.',
|
||||
'Packaged tools, skills, plugins, subagents, MCP servers, and policies live under `agent/`.',
|
||||
'`vendor/` contains the matching harness library; `.agent/` holds generated sessions and memory.',
|
||||
'The harness library is a normal dependency installed from the registry; `.agent/` holds generated sessions and memory.',
|
||||
'',
|
||||
'For a local MCP server, list its helper files and data in `files` so export can include them.',
|
||||
'`files` paths are relative to the saved configuration directory and are removed from exported SDK options.',
|
||||
@@ -543,21 +537,6 @@ function addDirectory(
|
||||
visit(root)
|
||||
}
|
||||
|
||||
function addTypeScriptPackage(zip: ZipFile, addBytes: (size: number) => void): void {
|
||||
const root = typescriptPackageRoot()
|
||||
for (const name of ['package.json', 'README.md', 'LICENSE', 'NOTICE']) {
|
||||
const path = join(root, name)
|
||||
if (regularFile(path)) {
|
||||
addFile(zip, path, `${VENDOR_PATH}/${name}`, addBytes)
|
||||
}
|
||||
}
|
||||
const dist = join(root, 'dist')
|
||||
if (!regularDirectory(dist)) {
|
||||
throw new Error('The installed TypeScript harness package is missing its dist directory.')
|
||||
}
|
||||
addDirectory(zip, dist, `${VENDOR_PATH}/dist`, addBytes)
|
||||
}
|
||||
|
||||
function addFile(zip: ZipFile, source: string, destination: string, addBytes: (size: number) => void): void {
|
||||
const details = lstatSync(source)
|
||||
if (!details.isFile()) {
|
||||
@@ -630,14 +609,19 @@ function packageRoot(entrypoint: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function pythonPackageRoot(): string {
|
||||
const packaged = join(packageRoot(fileURLToPath(import.meta.url)), 'dist', 'python')
|
||||
if (regularFile(join(packaged, 'pyproject.toml'))) {
|
||||
return packaged
|
||||
// The exported project depends on the harness the CLI itself resolved: `@strands-agents/harness` on
|
||||
// npm, `strands-harness` on PyPI. They release in parity from this repo, so the installed npm version
|
||||
// drives both specifiers.
|
||||
function harnessVersion(): string {
|
||||
const manifest = JSON.parse(readFileSync(join(typescriptPackageRoot(), 'package.json'), 'utf8')) as {
|
||||
version: string
|
||||
}
|
||||
throw new Error(
|
||||
'The Strands CLI is missing its bundled Python package. Rebuild or reinstall Strands before exporting.'
|
||||
)
|
||||
return manifest.version
|
||||
}
|
||||
|
||||
function harnessPythonSpecifier(): string {
|
||||
const [major, minor] = harnessVersion().split('.')
|
||||
return `~=${major}.${minor}.0`
|
||||
}
|
||||
|
||||
function writeZip(zip: ZipFile, destination: string, overwrite: boolean): Promise<void> {
|
||||
@@ -671,14 +655,6 @@ function writeZip(zip: ZipFile, destination: string, overwrite: boolean): Promis
|
||||
})
|
||||
}
|
||||
|
||||
function regularDirectory(path: string): boolean {
|
||||
try {
|
||||
return lstatSync(path).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function safeComponent(value: string): string {
|
||||
if (!value || value.includes('/') || value.includes('\\') || value === '.' || value === '..') {
|
||||
throw new Error('Exported projects contain an unsafe path component.')
|
||||
|
||||
@@ -334,11 +334,13 @@ export const agent = await createHarness({
|
||||
const imported = importAgentProject(extracted)
|
||||
expect(imported.language).toBe(language)
|
||||
if (language === 'python') {
|
||||
expect(entries.has('vendor/strands-harness/src/strands_harness/config.py')).toBe(true)
|
||||
expect(entries.get('requirements.txt')?.toString()).toContain('./vendor/strands-harness')
|
||||
expect([...entries.keys()].some((path) => path.startsWith('vendor/'))).toBe(false)
|
||||
expect(entries.get('requirements.txt')?.toString()).toMatch(/^strands-harness~=/m)
|
||||
expect(entries.get('requirements.txt')?.toString()).toContain('strands-agents[cedar]')
|
||||
} else {
|
||||
expect(JSON.parse(entries.get('package.json')!.toString()).dependencies['@cedar-policy/cedar-wasm']).toBeTruthy()
|
||||
const dependencies = JSON.parse(entries.get('package.json')!.toString()).dependencies
|
||||
expect(dependencies['@cedar-policy/cedar-wasm']).toBeTruthy()
|
||||
expect(dependencies['@strands-agents/harness']).toMatch(/^\^\d/)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -360,7 +362,7 @@ export const agent = await createHarness({
|
||||
const tsEntries = await readZipEntries(typescript)
|
||||
expect(JSON.parse(tsEntries.get('package.json')!.toString()).dependencies[dependency!]).toBeTruthy()
|
||||
const pyEntries = await readZipEntries(python)
|
||||
expect(pyEntries.get('requirements.txt')?.toString()).toContain(`./vendor/strands-harness[${extra}]`)
|
||||
expect(pyEntries.get('requirements.txt')?.toString()).toContain(`strands-harness[${extra}]~=`)
|
||||
})
|
||||
|
||||
it('includes a production build and points chat users to the Strands CLI', async () => {
|
||||
|
||||
Reference in New Issue
Block a user