From 0ce5f2b56428a3f68f5f098def5d80efc7ddb210 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sat, 8 Aug 2026 20:59:51 +0100 Subject: [PATCH] Harden release transport and local onboarding --- .agents/skills/release-yield/SKILL.md | 2 +- .../immutable-release-and-onboarding.md | 5 + .claude/skills/release-yield/SKILL.md | 2 +- .cursor/skills/release-yield/SKILL.md | 2 +- .github/workflows/npm-publish.yml | 23 ++- .github/workflows/release-finalize.yml | 11 ++ .github/workflows/verify.yml | 6 +- README.md | 13 +- cmd/yskill/agents.go | 3 + cmd/yskill/agents_test.go | 14 +- cmd/yskill/bootstrap.go | 9 +- cmd/yskill/bootstrap_templates.go | 8 +- cmd/yskill/main.go | 34 +++- cmd/yskill/main_test.go | 22 ++- cmd/yskill/scaffold.go | 16 +- cmd/yskill/skillmeta.go | 10 +- docs/agent-setup.md | 18 +-- docs/quickstart.md | 6 +- .../fixtures/yield/skills/release/skill.json | 2 +- evals/results/latest-conversion.json | 12 +- evals/results/latest.json | 4 +- evals/scripts/run.mjs | 10 +- examples/convert-skill/skill.json | 1 + examples/data-migration/skill.json | 1 + examples/env-doctor/skill.json | 1 + examples/investigate/skill.json | 1 + examples/library/go/audit-security/skill.json | 1 + .../library/go/investigate-failure/skill.json | 1 + .../library/go/migrate-database/skill.json | 1 + examples/library/go/publish-ios/skill.json | 1 + examples/library/go/qa-web-change/skill.json | 1 + .../library/go/release-package/skill.json | 1 + examples/library/go/repair-ci/skill.json | 1 + examples/library/go/review-branch/skill.json | 1 + examples/library/go/triage-issue/skill.json | 1 + .../library/go/upgrade-dependency/skill.json | 1 + .../library/python/audit-security/skill.json | 1 + .../python/investigate-failure/skill.json | 1 + .../python/migrate-database/skill.json | 1 + .../library/python/publish-ios/skill.json | 1 + .../library/python/qa-web-change/skill.json | 1 + .../library/python/release-package/skill.json | 1 + examples/library/python/repair-ci/skill.json | 1 + .../library/python/review-branch/skill.json | 1 + .../library/python/triage-issue/skill.json | 1 + .../python/upgrade-dependency/skill.json | 1 + .../library/rust/audit-security/skill.json | 1 + .../rust/investigate-failure/skill.json | 1 + .../library/rust/migrate-database/skill.json | 1 + examples/library/rust/publish-ios/skill.json | 1 + .../library/rust/qa-web-change/skill.json | 1 + .../library/rust/release-package/skill.json | 1 + examples/library/rust/repair-ci/skill.json | 1 + .../library/rust/review-branch/skill.json | 1 + examples/library/rust/triage-issue/skill.json | 1 + .../rust/upgrade-dependency/skill.json | 1 + .../typescript/audit-security/skill.json | 1 + .../typescript/investigate-failure/skill.json | 1 + .../typescript/migrate-database/skill.json | 1 + .../library/typescript/publish-ios/skill.json | 1 + .../typescript/qa-web-change/skill.json | 1 + .../typescript/release-package/skill.json | 1 + .../library/typescript/repair-ci/skill.json | 1 + .../typescript/review-branch/skill.json | 1 + .../typescript/triage-issue/skill.json | 1 + .../typescript/upgrade-dependency/skill.json | 1 + examples/release-checklist/skill.json | 1 + internal/conformance/conformance_test.go | 2 +- .../conformance/testdata/skill-py/skill.json | 3 + .../conformance/testdata/skill-rs/skill.json | 3 + .../conformance/testdata/skill-ts/skill.json | 3 + internal/engine/engine.go | 30 +++- internal/engine/engine_test.go | 50 +++++- packaging/assemble.mjs | 12 +- packaging/go-release.test.mjs | 4 +- packaging/npm-release.mjs | 108 +++++++++++++ packaging/npm-release.test.mjs | 40 +++++ packaging/package-contract.mjs | 145 ++++++++++++++++++ packaging/package-contract.test.mjs | 67 ++++++++ packaging/pypi-release.mjs | 6 +- scripts/check-release-control.mjs | 17 ++ scripts/readme.test.mjs | 25 +-- sdk/python/yieldskill/__init__.py | 19 +++ sdk/rust/README.md | 16 +- sdk/rust/src/lib.rs | 34 ++++ sdk/typescript/src/index.ts | 15 ++ sdk/yield/README.md | 15 +- sdk/yield/yield.go | 26 ++++ skills/release-yield/skill.json | 1 + .../release-yield/src/release-controller.mjs | 9 +- 90 files changed, 810 insertions(+), 117 deletions(-) create mode 100644 .changeset/immutable-release-and-onboarding.md create mode 100644 packaging/npm-release.mjs create mode 100644 packaging/npm-release.test.mjs create mode 100644 packaging/package-contract.mjs create mode 100644 packaging/package-contract.test.mjs diff --git a/.agents/skills/release-yield/SKILL.md b/.agents/skills/release-yield/SKILL.md index c9c4fa0..246d8e2 100644 --- a/.agents/skills/release-yield/SKILL.md +++ b/.agents/skills/release-yield/SKILL.md @@ -3,7 +3,7 @@ name: release-yield description: "Release Yield through its protected GitHub workflows and verify every public registry." --- - + This adapter exposes the canonical Yield workflow at `skills/release-yield`. Read its SKILL.md, then run from the repository root: diff --git a/.changeset/immutable-release-and-onboarding.md b/.changeset/immutable-release-and-onboarding.md new file mode 100644 index 0000000..4605855 --- /dev/null +++ b/.changeset/immutable-release-and-onboarding.md @@ -0,0 +1,5 @@ +--- +"@operatorstack/yield": patch +--- + +Publish verified npm archives instead of extracted directories, require each workflow to declare the running Yield version, and keep Go and Rust workflow launchers local to the project. diff --git a/.claude/skills/release-yield/SKILL.md b/.claude/skills/release-yield/SKILL.md index c9c4fa0..246d8e2 100644 --- a/.claude/skills/release-yield/SKILL.md +++ b/.claude/skills/release-yield/SKILL.md @@ -3,7 +3,7 @@ name: release-yield description: "Release Yield through its protected GitHub workflows and verify every public registry." --- - + This adapter exposes the canonical Yield workflow at `skills/release-yield`. Read its SKILL.md, then run from the repository root: diff --git a/.cursor/skills/release-yield/SKILL.md b/.cursor/skills/release-yield/SKILL.md index c9c4fa0..246d8e2 100644 --- a/.cursor/skills/release-yield/SKILL.md +++ b/.cursor/skills/release-yield/SKILL.md @@ -3,7 +3,7 @@ name: release-yield description: "Release Yield through its protected GitHub workflows and verify every public registry." --- - + This adapter exposes the canonical Yield workflow at `skills/release-yield`. Read its SKILL.md, then run from the repository root: diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 4f97aae..e775f3a 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -135,20 +135,17 @@ jobs: cp "$directory/target/package/${name}-${VERSION}.crate" dist/crates/ done test "$(find dist/crates -maxdepth 1 -name '*.crate' | wc -l | tr -d ' ')" = 6 - - name: Inspect npm tarballs + - name: Pack immutable npm release unit shell: bash run: | set -euo pipefail - for directory in dist/packages/npm/*; do - (cd "$directory" && npm pack --dry-run) - done + node packaging/npm-release.mjs --source dist/packages/npm --output dist/release-unit/npm --source-sha "${{ needs.resolve.outputs.source_sha }}" - name: Assemble immutable release unit env: CHANNEL: ${{ needs.resolve.outputs.channel }} run: | mkdir -p dist/release-unit cp dist/packages/SHA256SUMS.json dist/release-unit/ - cp -R dist/packages/npm dist/release-unit/npm if [[ "$CHANNEL" == stable ]]; then cp -R dist/pypi dist/release-unit/pypi cp -R dist/packages/rust dist/release-unit/rust @@ -186,12 +183,13 @@ jobs: shell: bash run: | set -euo pipefail - for directory in dist/release-unit/npm/darwin-* dist/release-unit/npm/linux-* dist/release-unit/npm/windows-*; do - package="$(node -p "require('./${directory}/package.json').name")" + jq -c '.archives[] | select(.name != "@operatorstack/yield" and .name != "@operatorstack/create-yield")' dist/release-unit/npm/npm-release.json | while read -r archive; do + package="$(jq -r '.name' <<< "$archive")" + file="$(jq -r '.file' <<< "$archive")" if npm view "${package}@${VERSION}" version >/dev/null 2>&1; then echo "${package}@${VERSION} already exists" else - (cd "$directory" && npm publish --tag "$DIST_TAG") + npm publish "dist/release-unit/npm/${file}" --tag "$DIST_TAG" fi done - name: Publish SDK and CLI @@ -204,7 +202,8 @@ jobs: if npm view "@operatorstack/yield@${VERSION}" version >/dev/null 2>&1; then echo "@operatorstack/yield@${VERSION} already exists" else - (cd dist/release-unit/npm/yield && npm publish --tag "$DIST_TAG") + file="$(jq -r '.archives[] | select(.name == "@operatorstack/yield") | .file' dist/release-unit/npm/npm-release.json)" + npm publish "dist/release-unit/npm/${file}" --tag "$DIST_TAG" fi - name: Publish npm initializer env: @@ -216,7 +215,8 @@ jobs: if npm view "@operatorstack/create-yield@${VERSION}" version >/dev/null 2>&1; then echo "@operatorstack/create-yield@${VERSION} already exists" else - (cd dist/release-unit/npm/create-yield && npm publish --tag "$DIST_TAG") + file="$(jq -r '.archives[] | select(.name == "@operatorstack/create-yield") | .file' dist/release-unit/npm/npm-release.json)" + npm publish "dist/release-unit/npm/${file}" --tag "$DIST_TAG" fi - name: Verify complete npm release unit env: @@ -224,8 +224,7 @@ jobs: shell: bash run: | set -euo pipefail - for directory in dist/release-unit/npm/*; do - package="$(node -p "require('./${directory}/package.json').name")" + jq -r '.archives[].name' dist/release-unit/npm/npm-release.json | while read -r package; do for attempt in {1..12}; do if [[ "$(npm view "${package}@${VERSION}" version 2>/dev/null || true)" == "$VERSION" ]]; then break; fi test "$attempt" -lt 12 diff --git a/.github/workflows/release-finalize.yml b/.github/workflows/release-finalize.yml index 99b23db..8fe3c93 100644 --- a/.github/workflows/release-finalize.yml +++ b/.github/workflows/release-finalize.yml @@ -123,5 +123,16 @@ jobs: --source-sha "$SOURCE_SHA" \ --attempts 3 \ --delay-ms 10000 + node evals/scripts/run.mjs --check test "$(git rev-list -n 1 "$TAG")" = "$SOURCE_SHA" + assets="$RUNNER_TEMP/release-assets" + mkdir -p "$assets" + node packaging/package-contract.mjs \ + --version "$version" \ + --source-sha "$SOURCE_SHA" \ + --npm-release "$RUNNER_TEMP/release-unit/npm/npm-release.json" \ + --evaluation evals/results/latest.json \ + --release-url "https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}" \ + --output "$assets" + gh release upload "$TAG" "$assets/yield-package-contract.json" "$assets/yield-website-promotion.json" --repo "$GITHUB_REPOSITORY" --clobber gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 137e3e1..1a68e3d 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -165,6 +165,7 @@ jobs: sdk/rust examples/data-migration examples/library/rust + - run: npm ci --ignore-scripts - name: Rerun first-party evaluations working-directory: evals run: | @@ -178,7 +179,10 @@ jobs: run: npm run test:conversion - name: Run every example fixture run: | - go build -o "$RUNNER_TEMP/yskill" ./cmd/yskill + plan="$RUNNER_TEMP/example-release-plan.env" + node scripts/release-plan.mjs --bump auto --output "$plan" --notes "$RUNNER_TEMP/example-release-notes.md" + . "$plan" + go build -ldflags "-X main.version=$version" -o "$RUNNER_TEMP/yskill" ./cmd/yskill "$RUNNER_TEMP/yskill" test examples/investigate "$RUNNER_TEMP/yskill" test examples/release-checklist "$RUNNER_TEMP/yskill" test examples/env-doctor diff --git a/README.md b/README.md index a2c9526..11e3d9a 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,12 @@ available for 73 more coding agents. Run the command for your project: -| Language | Command | -| ---------- | --------------------------------------------------------------------------------- | -| TypeScript | `npm create @operatorstack/yield@latest` | -| Python | `uvx --from yieldskill yskill bootstrap --language python` | -| Rust | `cargo install yieldskill --locked`, then `yskill bootstrap --language rust` | -| Go | `go run github.com/operatorstack/yield/cmd/yskill@latest bootstrap --language go` | +| Language | Command | +| ---------- | -------------------------------------------------------------------------------------------------------------- | +| TypeScript | `npm create @operatorstack/yield@latest` | +| Python | `uvx --from yieldskill yskill bootstrap --language python` | +| Rust | `cargo install yieldskill --root .yield --locked`, then `.yield/bin/yskill bootstrap --root . --language rust` | +| Go | `go run github.com/operatorstack/yield/cmd/yskill@latest bootstrap --root . --language go` | Yield detects the repository, language, and installed coding agents. It shows every proposed file and dependency change. It asks before it writes. It then @@ -159,6 +159,7 @@ workflow URL without publishing. Every newly published canary runs the same contract tests in an isolated CI lane. Stable release execution remains pinned to an exact public version. + ## Advanced: build manually diff --git a/cmd/yskill/agents.go b/cmd/yskill/agents.go index d4ae0f0..4e7576c 100644 --- a/cmd/yskill/agents.go +++ b/cmd/yskill/agents.go @@ -710,6 +710,9 @@ func verifyWorkflowSDKVersion(manifest skillManifest, skillDir, repoRoot, expect if expected == "dev" { return nil } + if manifest.YieldVersion != expected { + return fmt.Errorf("workflow declares Yield %s, but the running supervisor is Yield %s", manifest.YieldVersion, expected) + } var declared string if manifest.Language == "typescript" { root, err := findTypeScriptPackageRoot(skillDir, repoRoot) diff --git a/cmd/yskill/agents_test.go b/cmd/yskill/agents_test.go index d8124ad..5ffa3b0 100644 --- a/cmd/yskill/agents_test.go +++ b/cmd/yskill/agents_test.go @@ -67,7 +67,7 @@ func TestEveryRegistryAgentGeneratesAContainedAdapter(t *testing.T) { writeTestFile(t, filepath.Join(repo, "package.json"), `{"dependencies":{"@operatorstack/yield":"0.1.17"}}`) skill := filepath.Join(repo, "workflows", "review") writeTestFile(t, filepath.Join(skill, "SKILL.md"), "---\nname: review\ndescription: Review the branch when the user wants code checked before shipping.\n---\n") - writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"language":"typescript","run":["node","main.ts"]}`) + writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"yield_version":"0.1.23","language":"typescript","run":["node","main.ts"]}`) writeTestFile(t, filepath.Join(skill, "main.ts"), "export {}\n") registry, err := loadAgentRegistry() if err != nil { @@ -225,7 +225,7 @@ func TestRegisterAllPreflightsAndWritesEveryWorkflow(t *testing.T) { for _, name := range []string{"review", "release"} { skill := filepath.Join(repo, "skills", name) writeTestFile(t, filepath.Join(skill, "SKILL.md"), "---\nname: "+name+"\ndescription: Run "+name+" when the matching project workflow is requested.\n---\n") - writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"language":"typescript","run":["node","main.ts"]}`) + writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"yield_version":"0.1.23","language":"typescript","run":["node","main.ts"]}`) writeTestFile(t, filepath.Join(skill, "main.ts"), "export {}\n") } output := captureStdout(t, func() { @@ -256,7 +256,7 @@ func TestRegisterAllPruneRemovesOnlyOwnedAdapterFile(t *testing.T) { for _, name := range []string{"review", "release"} { skill := filepath.Join(repo, "skills", name) writeTestFile(t, filepath.Join(skill, "SKILL.md"), "---\nname: "+name+"\ndescription: Run "+name+" when the matching project workflow is requested.\n---\n") - writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"language":"typescript","run":["node","main.ts"]}`) + writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"yield_version":"0.1.23","language":"typescript","run":["node","main.ts"]}`) writeTestFile(t, filepath.Join(skill, "main.ts"), "export {}\n") } if err := cmdRegisterAll([]string{filepath.Join(repo, "skills"), "--root", repo, "--agent", "codex"}); err != nil { @@ -285,7 +285,7 @@ func TestRegisterAllRefusesCrossDirectoryNameCollisionBeforeWrites(t *testing.T) makeSkill := func(parent string) string { skill := filepath.Join(repo, parent, "review") writeTestFile(t, filepath.Join(skill, "SKILL.md"), "---\nname: review\ndescription: Review the project when a user asks for a check.\n---\n") - writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"language":"typescript","run":["node","main.ts"]}`) + writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"yield_version":"0.1.23","language":"typescript","run":["node","main.ts"]}`) writeTestFile(t, filepath.Join(skill, "main.ts"), "export {}\n") return skill } @@ -442,7 +442,7 @@ func TestWorkflowSDKVersionMustMatchRuntime(t *testing.T) { func TestGoWorkflowSDKVersionAcceptsGeneratedAndBlockForms(t *testing.T) { repo := t.TempDir() - manifest := skillManifest{Version: 1, Language: "go", Run: []string{"go", "run", "."}} + manifest := skillManifest{Version: 1, YieldVersion: "0.1.23", Language: "go", Run: []string{"go", "run", "."}} for name, goMod := range map[string]string{ "generated": "module review\n\ngo 1.26.5\n\nrequire github.com/operatorstack/yield v0.1.23\n", "block": "module review\n\ngo 1.26.5\n\nrequire (\n\tgithub.com/operatorstack/yield v0.1.23\n)\n", @@ -462,7 +462,7 @@ func TestDoctorReportsSDKRuntimeAndAdapterVersionProblemsTogether(t *testing.T) writeTestFile(t, filepath.Join(repo, ".git", "keep"), "") skill := filepath.Join(repo, "skills", "review") writeTestFile(t, filepath.Join(skill, "SKILL.md"), "---\nname: review\ndescription: Review code before it is shipped.\n---\n") - writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"language":"go","run":["go","run","."]}`) + writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"yield_version":"0.1.23","language":"go","run":["go","run","."]}`) writeTestFile(t, filepath.Join(skill, "go.mod"), "module review\n\ngo 1.26.5\n\nrequire github.com/operatorstack/yield v0.1.22\n") writeTestFile(t, filepath.Join(skill, "main.go"), "package main\nfunc main() {}\n") writeTestFile(t, localRuntimePath(repo), "runtime") @@ -491,7 +491,7 @@ func createTypeScriptSkill(t *testing.T, repo, name string) string { writeTestFile(t, filepath.Join(repo, "package.json"), `{"dependencies":{"@operatorstack/yield":"0.1.17"}}`) skill := filepath.Join(repo, "skills", name) writeTestFile(t, filepath.Join(skill, "SKILL.md"), "---\nname: "+name+"\ndescription: Review the branch when the user wants code checked before shipping.\n---\n") - writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"language":"typescript","run":["node","main.ts"]}`) + writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"yield_version":"0.1.23","language":"typescript","run":["node","main.ts"]}`) writeTestFile(t, filepath.Join(skill, "main.ts"), "export {}\n") return skill } diff --git a/cmd/yskill/bootstrap.go b/cmd/yskill/bootstrap.go index 46c2c91..e2365a0 100644 --- a/cmd/yskill/bootstrap.go +++ b/cmd/yskill/bootstrap.go @@ -11,6 +11,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "sort" "strings" ) @@ -247,8 +248,12 @@ func printBootstrapPlan(plan bootstrapPlan) { if plan.Dependency != "" { fmt.Printf(" run %s\n", plan.Dependency) } - fmt.Println(" run yskill doctor skills/yield-workflow-builder --test") - fmt.Println(" run yskill register skills/yield-workflow-builder") + launcher := "yskill" + if plan.Language == "go" || plan.Language == "rust" { + launcher = repositoryRuntimeLauncher(filepath.ToSlash(filepath.Join(".yield", "bin", "yskill")), runtime.GOOS) + } + fmt.Printf(" run %s doctor skills/yield-workflow-builder --root . --test\n", launcher) + fmt.Printf(" run %s register skills/yield-workflow-builder --root .\n", launcher) } func detectBootstrapLanguage(root string) (string, error) { diff --git a/cmd/yskill/bootstrap_templates.go b/cmd/yskill/bootstrap_templates.go index be72748..7428cd0 100644 --- a/cmd/yskill/bootstrap_templates.go +++ b/cmd/yskill/bootstrap_templates.go @@ -63,21 +63,21 @@ log. It is not a generated destination file. case "typescript": files["main.ts"] = bootstrapTypeScript files["package.json"] = fmt.Sprintf("{\n \"private\": true,\n \"type\": \"module\",\n \"dependencies\": { \"@operatorstack/yield\": \"%s\" }\n}\n", version) - files["skill.json"] = "{\"version\":1,\"language\":\"typescript\",\"run\":[\"node\",\"main.ts\"]}\n" + files["skill.json"] = fmt.Sprintf("{\"version\":1,\"yield_version\":%q,\"language\":\"typescript\",\"run\":[\"node\",\"main.ts\"]}\n", version) dependency = "npm install --ignore-scripts --no-audit --no-fund (inside skills/yield-workflow-builder)" case "python": files["main.py"] = bootstrapPython files["requirements.txt"] = fmt.Sprintf("yieldskill==%s\n", version) - files["skill.json"] = "{\"version\":1,\"language\":\"python\",\"run\":[\"python\",\"main.py\"]}\n" + files["skill.json"] = fmt.Sprintf("{\"version\":1,\"yield_version\":%q,\"language\":\"python\",\"run\":[\"python\",\"main.py\"]}\n", version) case "go": files["main.go"] = bootstrapGo files["go.mod"] = fmt.Sprintf("module yield-workflow-builder\n\ngo 1.26.5\n\nrequire github.com/operatorstack/yield v%s\n", version) - files["skill.json"] = "{\"version\":1,\"language\":\"go\",\"run\":[\"go\",\"run\",\"-mod=readonly\",\".\"]}\n" + files["skill.json"] = fmt.Sprintf("{\"version\":1,\"yield_version\":%q,\"language\":\"go\",\"run\":[\"go\",\"run\",\"-mod=readonly\",\".\"]}\n", version) dependency = "go mod tidy (inside skills/yield-workflow-builder)" case "rust": files["src/main.rs"] = bootstrapRust files["Cargo.toml"] = fmt.Sprintf("[package]\nname = \"yield-workflow-builder\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nyieldskill = { version = \"=%s\" }\nserde_json = \"1\"\n", version) - files["skill.json"] = "{\"version\":1,\"language\":\"rust\",\"run\":[\"cargo\",\"run\",\"--quiet\"]}\n" + files["skill.json"] = fmt.Sprintf("{\"version\":1,\"yield_version\":%q,\"language\":\"rust\",\"run\":[\"cargo\",\"run\",\"--quiet\"]}\n", version) default: return nil, "", fmt.Errorf("unsupported language %q", language) } diff --git a/cmd/yskill/main.go b/cmd/yskill/main.go index 4e3191d..152fe09 100644 --- a/cmd/yskill/main.go +++ b/cmd/yskill/main.go @@ -56,6 +56,24 @@ Usage: var version = "dev" var readBuildInfo = debug.ReadBuildInfo +func newEngine(skillDir string) (*engine.Engine, error) { + e, err := engine.New(skillDir) + if err != nil { + return nil, err + } + e.SupervisorVersion = runtimeVersion() + return e, nil +} + +func newEngineWithRunsDir(skillDir, runsDir string) (*engine.Engine, error) { + e, err := engine.NewWithRunsDir(skillDir, runsDir) + if err != nil { + return nil, err + } + e.SupervisorVersion = runtimeVersion() + return e, nil +} + func runtimeVersion() string { if version != "" && version != "dev" { return strings.TrimPrefix(version, "v") @@ -122,7 +140,7 @@ func cmdRun(args []string) error { if fs.NArg() != 1 { return fmt.Errorf("run takes exactly one skill directory") } - e, err := engine.New(fs.Arg(0)) + e, err := newEngine(fs.Arg(0)) if err != nil { return err } @@ -152,7 +170,7 @@ func cmdResume(args []string) error { if fs.NArg() != 1 || *response == "" { return fmt.Errorf("resume takes a run id and --response file") } - e, err := engine.New(*skillDir) + e, err := newEngine(*skillDir) if err != nil { return err } @@ -180,7 +198,7 @@ func cmdRespond(args []string) error { if fs.NArg() != 1 || seen["value"] == seen["result-json"] { return fmt.Errorf("respond takes one run id and exactly one of --value or --result-json") } - e, err := engine.New(*skillDir) + e, err := newEngine(*skillDir) if err != nil { return err } @@ -218,7 +236,7 @@ func cmdInspect(args []string) error { if err := parseOnePositional(fs, args); err != nil { return err } - e, err := engine.New(*skillDir) + e, err := newEngine(*skillDir) if err != nil { return err } @@ -253,7 +271,7 @@ func cmdPrune(args []string) error { if fs.NArg() != 1 || *olderThan <= 0 || *keepLast < 0 { return fmt.Errorf("prune takes one skill directory, --older-than greater than zero, and a nonnegative --keep-last") } - e, err := engine.New(fs.Arg(0)) + e, err := newEngine(fs.Arg(0)) if err != nil { return err } @@ -315,7 +333,7 @@ func cmdReplay(args []string) error { if fs.NArg() != 1 { return fmt.Errorf("replay takes exactly one run id") } - e, err := engine.New(*skillDir) + e, err := newEngine(*skillDir) if err != nil { return err } @@ -362,13 +380,13 @@ func cmdTest(args []string) (retErr error) { } var e *engine.Engine if *keepRun { - e, err = engine.New(dir) + e, err = newEngine(dir) } else { var runs string runs, err = os.MkdirTemp("", "yield-test-runs-*") if err == nil { defer os.RemoveAll(runs) - e, err = engine.NewWithRunsDir(dir, runs) + e, err = newEngineWithRunsDir(dir, runs) } } if err != nil { diff --git a/cmd/yskill/main_test.go b/cmd/yskill/main_test.go index 02f6e41..dcc0c74 100644 --- a/cmd/yskill/main_test.go +++ b/cmd/yskill/main_test.go @@ -207,8 +207,8 @@ func TestScaffoldSkillWritesLanguageSpecificEntrypoints(t *testing.T) { if err != nil { t.Fatal(err) } - if generated.Version != 1 || generated.Language != tt.language { - t.Fatalf("skill.json = version %d language %q", generated.Version, generated.Language) + if generated.Version != 1 || generated.YieldVersion != "0.1.9" || generated.Language != tt.language { + t.Fatalf("skill.json = version %d yield version %q language %q", generated.Version, generated.YieldVersion, generated.Language) } skill := readTestFile(t, filepath.Join(dir, "SKILL.md")) workflow := shellQuoteForPlatform(dir, runtime.GOOS) @@ -276,7 +276,7 @@ func TestPackageScaffoldsPrintCreatedWorkflowInNextCommands(t *testing.T) { workflow := shellQuoteForPlatform(dir, runtime.GOOS) for _, line := range []string{ "test: " + tt.launcher + " doctor " + workflow + " --test", - "then: " + tt.launcher + " register " + workflow, + "then: " + tt.launcher + " register " + workflow + " --root .", } { if !strings.Contains(output, line) { t.Fatalf("init output does not contain %q:\n%s", line, output) @@ -286,6 +286,18 @@ func TestPackageScaffoldsPrintCreatedWorkflowInNextCommands(t *testing.T) { } } +func TestSkillManifestRequiresExactYieldVersion(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, filepath.Join(dir, "skill.json"), `{"version":1,"language":"typescript","run":["node","main.ts"]}`) + if _, err := readSkillManifest(dir); err == nil || !strings.Contains(err.Error(), "requires an exact yield_version") { + t.Fatalf("missing yield_version error = %v", err) + } + writeTestFile(t, filepath.Join(dir, "skill.json"), `{"version":3,"yield_version":"0.2.0","language":"typescript","run":["node","main.ts"]}`) + if _, err := readSkillManifest(dir); err == nil || !strings.Contains(err.Error(), "version must be 1") { + t.Fatalf("non-v1 schema error = %v", err) + } +} + func TestShellQuoteForPlatform(t *testing.T) { for _, tt := range []struct { name, value, goos, want string @@ -313,7 +325,7 @@ func TestRustScaffoldNamesPrimaryBinary(t *testing.T) { t.Fatal(err) } manifest := readTestFile(t, filepath.Join(dir, "skill.json")) - if manifest != "{\"version\":1,\"language\":\"rust\",\"run\":[\"cargo\",\"run\",\"--quiet\",\"--bin\",\"safe-change\"]}\n" { + if manifest != "{\"version\":1,\"yield_version\":\"0.1.28\",\"language\":\"rust\",\"run\":[\"cargo\",\"run\",\"--quiet\",\"--bin\",\"safe-change\"]}\n" { t.Fatalf("skill.json = %q", manifest) } } @@ -358,7 +370,7 @@ func TestGoScaffoldCanResolveItsPinnedModuleOnFirstRun(t *testing.T) { t.Fatal(err) } manifest := readTestFile(t, filepath.Join(dir, "skill.json")) - if manifest != "{\"version\":1,\"language\":\"go\",\"run\":[\"go\",\"run\",\"-mod=readonly\",\".\"]}\n" { + if manifest != "{\"version\":1,\"yield_version\":\"0.1.9\",\"language\":\"go\",\"run\":[\"go\",\"run\",\"-mod=readonly\",\".\"]}\n" { t.Fatalf("skill.json = %q", manifest) } } diff --git a/cmd/yskill/scaffold.go b/cmd/yskill/scaffold.go index 32560e3..3113a39 100644 --- a/cmd/yskill/scaffold.go +++ b/cmd/yskill/scaffold.go @@ -149,7 +149,13 @@ func scaffoldSkill(dir, language, sdkPath, description string) error { fmt.Printf("init: %s skill %q scaffolded in %s\n", language, name, dir) fmt.Println("next: replace the starter program and fixtures with the described workflow") fmt.Printf("test: %s doctor %s --test\n", launcher, workflow) - fmt.Printf("then: %s register %s\n", launcher, workflow) + rootFlag := "" + if language == "typescript" || language == "python" { + if _, err := findRepoRoot(dir, ""); err != nil { + rootFlag = " --root ." + } + } + fmt.Printf("then: %s register %s%s\n", launcher, workflow, rootFlag) return nil } @@ -240,19 +246,19 @@ func scaffoldFiles(name, language, sdkPath string) map[string]string { "dependencies": { "@operatorstack/yield": "%s" } } `, v), - "skill.json": "{\"version\":1,\"language\":\"typescript\",\"run\":[\"node\",\"main.ts\"]}\n", + "skill.json": fmt.Sprintf("{\"version\":1,\"yield_version\":%q,\"language\":\"typescript\",\"run\":[\"node\",\"main.ts\"]}\n", v), } case "python": return map[string]string{ "main.py": mainPython, "requirements.txt": fmt.Sprintf("yieldskill==%s\n", v), - "skill.json": "{\"version\":1,\"language\":\"python\",\"run\":[\"python\",\"main.py\"]}\n", + "skill.json": fmt.Sprintf("{\"version\":1,\"yield_version\":%q,\"language\":\"python\",\"run\":[\"python\",\"main.py\"]}\n", v), } case "rust": return map[string]string{ "Cargo.toml": fmt.Sprintf("[package]\nname = %q\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nyieldskill = { version = \"=%s\" }\nserde_json = \"1\"\n", name, v), "src/main.rs": mainRust, - "skill.json": fmt.Sprintf("{\"version\":1,\"language\":\"rust\",\"run\":[\"cargo\",\"run\",\"--quiet\",\"--bin\",%q]}\n", name), + "skill.json": fmt.Sprintf("{\"version\":1,\"yield_version\":%q,\"language\":\"rust\",\"run\":[\"cargo\",\"run\",\"--quiet\",\"--bin\",%q]}\n", v, name), } default: gomod := fmt.Sprintf("module %s\n\ngo 1.26.5\n\nrequire github.com/operatorstack/yield v%s\n", name, v) @@ -262,7 +268,7 @@ func scaffoldFiles(name, language, sdkPath string) map[string]string { return map[string]string{ "main.go": mainGo, "go.mod": gomod, - "skill.json": "{\"version\":1,\"language\":\"go\",\"run\":[\"go\",\"run\",\"-mod=readonly\",\".\"]}\n", + "skill.json": fmt.Sprintf("{\"version\":1,\"yield_version\":%q,\"language\":\"go\",\"run\":[\"go\",\"run\",\"-mod=readonly\",\".\"]}\n", v), } } } diff --git a/cmd/yskill/skillmeta.go b/cmd/yskill/skillmeta.go index de399e6..72ba296 100644 --- a/cmd/yskill/skillmeta.go +++ b/cmd/yskill/skillmeta.go @@ -15,9 +15,10 @@ import ( var portableSkillName = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) type skillManifest struct { - Version int `json:"version"` - Language string `json:"language"` - Run []string `json:"run"` + Version int `json:"version"` + YieldVersion string `json:"yield_version"` + Language string `json:"language"` + Run []string `json:"run"` } type skillMetadata struct { @@ -37,6 +38,9 @@ func readSkillManifest(dir string) (skillManifest, error) { if manifest.Version != 1 { return skillManifest{}, fmt.Errorf("skill.json version must be 1") } + if !releaseVersionPattern.MatchString(manifest.YieldVersion) { + return skillManifest{}, fmt.Errorf("skill.json version 1 requires an exact yield_version") + } switch manifest.Language { case "typescript", "python", "go", "rust": default: diff --git a/docs/agent-setup.md b/docs/agent-setup.md index 5cd8d10..b852a05 100644 --- a/docs/agent-setup.md +++ b/docs/agent-setup.md @@ -8,20 +8,20 @@ adapters required for agent discovery. ```bash # Detect installed verified agents -yskill register skills/review +.yield/bin/yskill register skills/review --root . # Or choose agents explicitly -yskill register skills/review --agent cursor,codex,claude-code +.yield/bin/yskill register skills/review --root . --agent cursor,codex,claude-code # Check the workflow itself -yskill doctor skills/review --test +.yield/bin/yskill doctor skills/review --root . --test # Check selected adapters too -yskill doctor skills/review --agent cursor,codex,claude-code --test +.yield/bin/yskill doctor skills/review --root . --agent cursor,codex,claude-code --test # Register a complete workflow directory in one pass -yskill register-all skills --agent cursor,codex,claude-code --dry-run -yskill register-all skills --agent cursor,codex,claude-code --prune +.yield/bin/yskill register-all skills --root . --agent cursor,codex,claude-code --dry-run +.yield/bin/yskill register-all skills --root . --agent cursor,codex,claude-code --prune ``` Use the launcher installed by the selected language package: @@ -81,11 +81,11 @@ npm create @operatorstack/yield@latest uvx --from yieldskill yskill bootstrap --language python # Rust -cargo install yieldskill --locked -yskill bootstrap --language rust +cargo install yieldskill --root .yield --locked +.yield/bin/yskill bootstrap --root . --language rust # Go -go run github.com/operatorstack/yield/cmd/yskill@latest bootstrap --language go +go run github.com/operatorstack/yield/cmd/yskill@latest bootstrap --root . --language go ``` Bootstrap shows every proposed change. Confirm the plan. Restart the coding diff --git a/docs/quickstart.md b/docs/quickstart.md index d796df1..c3ff512 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -15,11 +15,11 @@ npm create @operatorstack/yield@latest uvx --from yieldskill yskill bootstrap --language python # Rust -cargo install yieldskill --locked -yskill bootstrap --language rust +cargo install yieldskill --root .yield --locked +.yield/bin/yskill bootstrap --root . --language rust # Go -go run github.com/operatorstack/yield/cmd/yskill@latest bootstrap --language go +go run github.com/operatorstack/yield/cmd/yskill@latest bootstrap --root . --language go ``` Bootstrap detects installed Codex, Claude Code, and Cursor project adapters. diff --git a/evals/agent/fixtures/yield/skills/release/skill.json b/evals/agent/fixtures/yield/skills/release/skill.json index 8b8a91c..3020aa4 100644 --- a/evals/agent/fixtures/yield/skills/release/skill.json +++ b/evals/agent/fixtures/yield/skills/release/skill.json @@ -1 +1 @@ -{"run":["node","main.ts"]} +{"version":1,"yield_version":"0.1.38","language":"typescript","run":["node","main.ts"]} diff --git a/evals/results/latest-conversion.json b/evals/results/latest-conversion.json index c957617..1a19fd5 100644 --- a/evals/results/latest-conversion.json +++ b/evals/results/latest-conversion.json @@ -1,8 +1,8 @@ { "schema_version": 1, "methodology_version": "semantic-disposition-v1", - "generated_at": "2026-08-08T11:09:09.457Z", - "source_hash": "5698f566b14881eec878509254d22cf558b1ce60e1109fa3c5b67cdb5a6d4752", + "generated_at": "2026-08-08T20:17:16.573Z", + "source_hash": "fa720eb84c6411442ff0e7296423a95a533c997c40c3047ae1128aa19a5dbe2c", "fixture_source_hash": "9ab03fffe6716da8298b461f79c9eebae7c7bb01151328686ec51ed9c0b77fe5", "status": "passed", "model": { @@ -13,10 +13,10 @@ }, "sessions": 2, "token_usage": { - "input_tokens": 704111, - "cached_input_tokens": 637615, - "output_tokens": 10985, - "reasoning_output_tokens": 3084 + "input_tokens": 495493, + "cached_input_tokens": 446404, + "output_tokens": 7931, + "reasoning_output_tokens": 1862 }, "clause_counts": { "total": 4, diff --git a/evals/results/latest.json b/evals/results/latest.json index e080967..d774971 100644 --- a/evals/results/latest.json +++ b/evals/results/latest.json @@ -1,8 +1,8 @@ { "schema_version": 2, "methodology_version": "1.1", - "generated_at": "2026-08-08T11:05:49.678Z", - "source_digest": "6b370013cbe8327035d4b34df6116786e89fd6fdb64d1de7436261ab801f8bc3", + "generated_at": "2026-08-08T20:17:45.135Z", + "source_digest": "a5e563abbe348dc60c3e8643beb2778acec8382d880c1cc08504158f8a5cb244", "status": "passed", "workflow_conformance": { "passed": 40, diff --git a/evals/scripts/run.mjs b/evals/scripts/run.mjs index f27abf5..5abcbcc 100644 --- a/evals/scripts/run.mjs +++ b/evals/scripts/run.mjs @@ -59,6 +59,7 @@ const runtimeCases = [ "changed source is refused until the user accepts the change", ], ] +const workflowRuntimeVersion = "0.2.0" const excludedDirectories = new Set([ ".git", ".yield", @@ -147,7 +148,14 @@ async function evaluate() { const temporary = await mkdtemp(join(tmpdir(), "yield-evals-")) try { const yskill = join(temporary, "yskill") - execute("go", ["build", "-o", yskill, "./cmd/yskill"]) + execute("go", [ + "build", + "-ldflags", + `-X main.version=${workflowRuntimeVersion}`, + "-o", + yskill, + "./cmd/yskill", + ]) const { catalog, cases } = await workflowCases(yskill) const invariants = evaluateRuntime() return { diff --git a/examples/convert-skill/skill.json b/examples/convert-skill/skill.json index da0e878..a4300d8 100644 --- a/examples/convert-skill/skill.json +++ b/examples/convert-skill/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "go", "run": ["go", "run", "."] } diff --git a/examples/data-migration/skill.json b/examples/data-migration/skill.json index 455b6ba..ad5875a 100644 --- a/examples/data-migration/skill.json +++ b/examples/data-migration/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "rust", "run": ["cargo", "run", "--quiet"] } diff --git a/examples/env-doctor/skill.json b/examples/env-doctor/skill.json index be4d4b6..a22630d 100644 --- a/examples/env-doctor/skill.json +++ b/examples/env-doctor/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "python", "run": ["python3", "main.py"] } diff --git a/examples/investigate/skill.json b/examples/investigate/skill.json index da0e878..a4300d8 100644 --- a/examples/investigate/skill.json +++ b/examples/investigate/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "go", "run": ["go", "run", "."] } diff --git a/examples/library/go/audit-security/skill.json b/examples/library/go/audit-security/skill.json index 9039b2d..67530de 100644 --- a/examples/library/go/audit-security/skill.json +++ b/examples/library/go/audit-security/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "go", "run": [ "go", diff --git a/examples/library/go/investigate-failure/skill.json b/examples/library/go/investigate-failure/skill.json index e8e261b..37f5150 100644 --- a/examples/library/go/investigate-failure/skill.json +++ b/examples/library/go/investigate-failure/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "go", "run": [ "go", diff --git a/examples/library/go/migrate-database/skill.json b/examples/library/go/migrate-database/skill.json index 3162de1..5a0913f 100644 --- a/examples/library/go/migrate-database/skill.json +++ b/examples/library/go/migrate-database/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "go", "run": [ "go", diff --git a/examples/library/go/publish-ios/skill.json b/examples/library/go/publish-ios/skill.json index dba9009..0f49ece 100644 --- a/examples/library/go/publish-ios/skill.json +++ b/examples/library/go/publish-ios/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "go", "run": [ "go", diff --git a/examples/library/go/qa-web-change/skill.json b/examples/library/go/qa-web-change/skill.json index 677da9f..d87b9bd 100644 --- a/examples/library/go/qa-web-change/skill.json +++ b/examples/library/go/qa-web-change/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "go", "run": [ "go", diff --git a/examples/library/go/release-package/skill.json b/examples/library/go/release-package/skill.json index 1615910..11fb08a 100644 --- a/examples/library/go/release-package/skill.json +++ b/examples/library/go/release-package/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "go", "run": [ "go", diff --git a/examples/library/go/repair-ci/skill.json b/examples/library/go/repair-ci/skill.json index a4debd9..6218aed 100644 --- a/examples/library/go/repair-ci/skill.json +++ b/examples/library/go/repair-ci/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "go", "run": [ "go", diff --git a/examples/library/go/review-branch/skill.json b/examples/library/go/review-branch/skill.json index 1a40e27..b0cb57c 100644 --- a/examples/library/go/review-branch/skill.json +++ b/examples/library/go/review-branch/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "go", "run": [ "go", diff --git a/examples/library/go/triage-issue/skill.json b/examples/library/go/triage-issue/skill.json index 2b18059..7a7ea0b 100644 --- a/examples/library/go/triage-issue/skill.json +++ b/examples/library/go/triage-issue/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "go", "run": [ "go", diff --git a/examples/library/go/upgrade-dependency/skill.json b/examples/library/go/upgrade-dependency/skill.json index df74f8f..7f1beb0 100644 --- a/examples/library/go/upgrade-dependency/skill.json +++ b/examples/library/go/upgrade-dependency/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "go", "run": [ "go", diff --git a/examples/library/python/audit-security/skill.json b/examples/library/python/audit-security/skill.json index 57b14bf..a0c3008 100644 --- a/examples/library/python/audit-security/skill.json +++ b/examples/library/python/audit-security/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "python", "run": [ "python3", diff --git a/examples/library/python/investigate-failure/skill.json b/examples/library/python/investigate-failure/skill.json index 44c2e78..9105734 100644 --- a/examples/library/python/investigate-failure/skill.json +++ b/examples/library/python/investigate-failure/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "python", "run": [ "python3", diff --git a/examples/library/python/migrate-database/skill.json b/examples/library/python/migrate-database/skill.json index f9f04e3..e544c0f 100644 --- a/examples/library/python/migrate-database/skill.json +++ b/examples/library/python/migrate-database/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "python", "run": [ "python3", diff --git a/examples/library/python/publish-ios/skill.json b/examples/library/python/publish-ios/skill.json index 4d1c494..258e8ee 100644 --- a/examples/library/python/publish-ios/skill.json +++ b/examples/library/python/publish-ios/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "python", "run": [ "python3", diff --git a/examples/library/python/qa-web-change/skill.json b/examples/library/python/qa-web-change/skill.json index c8b2ed3..77c9ff2 100644 --- a/examples/library/python/qa-web-change/skill.json +++ b/examples/library/python/qa-web-change/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "python", "run": [ "python3", diff --git a/examples/library/python/release-package/skill.json b/examples/library/python/release-package/skill.json index 39707ca..6c57303 100644 --- a/examples/library/python/release-package/skill.json +++ b/examples/library/python/release-package/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "python", "run": [ "python3", diff --git a/examples/library/python/repair-ci/skill.json b/examples/library/python/repair-ci/skill.json index a17bf94..57cd540 100644 --- a/examples/library/python/repair-ci/skill.json +++ b/examples/library/python/repair-ci/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "python", "run": [ "python3", diff --git a/examples/library/python/review-branch/skill.json b/examples/library/python/review-branch/skill.json index 223fd5c..fc1e014 100644 --- a/examples/library/python/review-branch/skill.json +++ b/examples/library/python/review-branch/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "python", "run": [ "python3", diff --git a/examples/library/python/triage-issue/skill.json b/examples/library/python/triage-issue/skill.json index 7a84c88..0c4283f 100644 --- a/examples/library/python/triage-issue/skill.json +++ b/examples/library/python/triage-issue/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "python", "run": [ "python3", diff --git a/examples/library/python/upgrade-dependency/skill.json b/examples/library/python/upgrade-dependency/skill.json index 6512802..801dd30 100644 --- a/examples/library/python/upgrade-dependency/skill.json +++ b/examples/library/python/upgrade-dependency/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "python", "run": [ "python3", diff --git a/examples/library/rust/audit-security/skill.json b/examples/library/rust/audit-security/skill.json index 1bd1f2d..66061e6 100644 --- a/examples/library/rust/audit-security/skill.json +++ b/examples/library/rust/audit-security/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "rust", "run": [ "cargo", diff --git a/examples/library/rust/investigate-failure/skill.json b/examples/library/rust/investigate-failure/skill.json index a358d69..15ea4ac 100644 --- a/examples/library/rust/investigate-failure/skill.json +++ b/examples/library/rust/investigate-failure/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "rust", "run": [ "cargo", diff --git a/examples/library/rust/migrate-database/skill.json b/examples/library/rust/migrate-database/skill.json index 0fd48dd..236b8b3 100644 --- a/examples/library/rust/migrate-database/skill.json +++ b/examples/library/rust/migrate-database/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "rust", "run": [ "cargo", diff --git a/examples/library/rust/publish-ios/skill.json b/examples/library/rust/publish-ios/skill.json index 507524c..5c0b1bc 100644 --- a/examples/library/rust/publish-ios/skill.json +++ b/examples/library/rust/publish-ios/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "rust", "run": [ "cargo", diff --git a/examples/library/rust/qa-web-change/skill.json b/examples/library/rust/qa-web-change/skill.json index 7ce1e22..52a59fe 100644 --- a/examples/library/rust/qa-web-change/skill.json +++ b/examples/library/rust/qa-web-change/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "rust", "run": [ "cargo", diff --git a/examples/library/rust/release-package/skill.json b/examples/library/rust/release-package/skill.json index f5c4340..ebd0dc4 100644 --- a/examples/library/rust/release-package/skill.json +++ b/examples/library/rust/release-package/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "rust", "run": [ "cargo", diff --git a/examples/library/rust/repair-ci/skill.json b/examples/library/rust/repair-ci/skill.json index 6e6271c..5bed648 100644 --- a/examples/library/rust/repair-ci/skill.json +++ b/examples/library/rust/repair-ci/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "rust", "run": [ "cargo", diff --git a/examples/library/rust/review-branch/skill.json b/examples/library/rust/review-branch/skill.json index 76823f4..5acb74c 100644 --- a/examples/library/rust/review-branch/skill.json +++ b/examples/library/rust/review-branch/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "rust", "run": [ "cargo", diff --git a/examples/library/rust/triage-issue/skill.json b/examples/library/rust/triage-issue/skill.json index 4ff4f09..6d51728 100644 --- a/examples/library/rust/triage-issue/skill.json +++ b/examples/library/rust/triage-issue/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "rust", "run": [ "cargo", diff --git a/examples/library/rust/upgrade-dependency/skill.json b/examples/library/rust/upgrade-dependency/skill.json index a4694f5..91dc38f 100644 --- a/examples/library/rust/upgrade-dependency/skill.json +++ b/examples/library/rust/upgrade-dependency/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "rust", "run": [ "cargo", diff --git a/examples/library/typescript/audit-security/skill.json b/examples/library/typescript/audit-security/skill.json index 71602c4..41712bf 100644 --- a/examples/library/typescript/audit-security/skill.json +++ b/examples/library/typescript/audit-security/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "typescript", "run": [ "node", diff --git a/examples/library/typescript/investigate-failure/skill.json b/examples/library/typescript/investigate-failure/skill.json index 25f2c12..af3887c 100644 --- a/examples/library/typescript/investigate-failure/skill.json +++ b/examples/library/typescript/investigate-failure/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "typescript", "run": [ "node", diff --git a/examples/library/typescript/migrate-database/skill.json b/examples/library/typescript/migrate-database/skill.json index 8cf2dd5..65d3af9 100644 --- a/examples/library/typescript/migrate-database/skill.json +++ b/examples/library/typescript/migrate-database/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "typescript", "run": [ "node", diff --git a/examples/library/typescript/publish-ios/skill.json b/examples/library/typescript/publish-ios/skill.json index d9709e5..58e9f46 100644 --- a/examples/library/typescript/publish-ios/skill.json +++ b/examples/library/typescript/publish-ios/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "typescript", "run": [ "node", diff --git a/examples/library/typescript/qa-web-change/skill.json b/examples/library/typescript/qa-web-change/skill.json index e1bce27..6f1a6b7 100644 --- a/examples/library/typescript/qa-web-change/skill.json +++ b/examples/library/typescript/qa-web-change/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "typescript", "run": [ "node", diff --git a/examples/library/typescript/release-package/skill.json b/examples/library/typescript/release-package/skill.json index 6ce7a80..a72f293 100644 --- a/examples/library/typescript/release-package/skill.json +++ b/examples/library/typescript/release-package/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "typescript", "run": [ "node", diff --git a/examples/library/typescript/repair-ci/skill.json b/examples/library/typescript/repair-ci/skill.json index 71c3376..519d726 100644 --- a/examples/library/typescript/repair-ci/skill.json +++ b/examples/library/typescript/repair-ci/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "typescript", "run": [ "node", diff --git a/examples/library/typescript/review-branch/skill.json b/examples/library/typescript/review-branch/skill.json index 1d897e4..d671345 100644 --- a/examples/library/typescript/review-branch/skill.json +++ b/examples/library/typescript/review-branch/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "typescript", "run": [ "node", diff --git a/examples/library/typescript/triage-issue/skill.json b/examples/library/typescript/triage-issue/skill.json index 584ce67..bacbd6e 100644 --- a/examples/library/typescript/triage-issue/skill.json +++ b/examples/library/typescript/triage-issue/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "typescript", "run": [ "node", diff --git a/examples/library/typescript/upgrade-dependency/skill.json b/examples/library/typescript/upgrade-dependency/skill.json index 536fd2d..8808940 100644 --- a/examples/library/typescript/upgrade-dependency/skill.json +++ b/examples/library/typescript/upgrade-dependency/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "typescript", "run": [ "node", diff --git a/examples/release-checklist/skill.json b/examples/release-checklist/skill.json index e54de2c..93f17cc 100644 --- a/examples/release-checklist/skill.json +++ b/examples/release-checklist/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.2.0", "language": "typescript", "run": ["node", "main.ts"] } diff --git a/internal/conformance/conformance_test.go b/internal/conformance/conformance_test.go index 88e83ab..d0f9af2 100644 --- a/internal/conformance/conformance_test.go +++ b/internal/conformance/conformance_test.go @@ -47,7 +47,7 @@ func newEngine(t *testing.T, lang language) *engine.Engine { if err != nil { t.Fatal(err) } - return &engine.Engine{SkillDir: abs, RunsDir: t.TempDir(), Stderr: os.Stderr} + return &engine.Engine{SkillDir: abs, RunsDir: t.TempDir(), Stderr: os.Stderr, SupervisorVersion: "dev"} } func respond(t *testing.T, e *engine.Engine, p *engine.Progress, result string) (*engine.Progress, error) { diff --git a/internal/conformance/testdata/skill-py/skill.json b/internal/conformance/testdata/skill-py/skill.json index 2334634..62028dd 100644 --- a/internal/conformance/testdata/skill-py/skill.json +++ b/internal/conformance/testdata/skill-py/skill.json @@ -1,3 +1,6 @@ { + "version": 1, + "yield_version": "0.1.38", + "language": "python", "run": ["python3", "main.py"] } diff --git a/internal/conformance/testdata/skill-rs/skill.json b/internal/conformance/testdata/skill-rs/skill.json index 5b767f7..8793416 100644 --- a/internal/conformance/testdata/skill-rs/skill.json +++ b/internal/conformance/testdata/skill-rs/skill.json @@ -1,3 +1,6 @@ { + "version": 1, + "yield_version": "0.1.38", + "language": "rust", "run": ["cargo", "run", "--quiet"] } diff --git a/internal/conformance/testdata/skill-ts/skill.json b/internal/conformance/testdata/skill-ts/skill.json index 36115dd..292b989 100644 --- a/internal/conformance/testdata/skill-ts/skill.json +++ b/internal/conformance/testdata/skill-ts/skill.json @@ -1,3 +1,6 @@ { + "version": 1, + "yield_version": "0.1.38", + "language": "typescript", "run": ["node", "main.ts"] } diff --git a/internal/engine/engine.go b/internal/engine/engine.go index e2f0dd7..2fe9ffd 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -25,8 +25,9 @@ import ( // Engine binds a skill directory to a runs directory. type Engine struct { - SkillDir string - RunsDir string + SkillDir string + RunsDir string + SupervisorVersion string // Stderr receives subprocess diagnostics (compile errors etc.). Stderr *os.File } @@ -424,7 +425,30 @@ func (e *Engine) execute(l *runlog.Log, runID string) (*protocol.ProgramOutput, defer cancel() cmd := exec.CommandContext(ctx, runner[0], runner[1:]...) cmd.Dir = e.SkillDir - cmd.Env = append(os.Environ(), "YIELD_JOURNAL="+jf.Name()) + environment := append(os.Environ(), "YIELD_JOURNAL="+jf.Name()) + if b, readErr := os.ReadFile(filepath.Join(e.SkillDir, "skill.json")); readErr == nil { + var manifest struct { + Version int `json:"version"` + YieldVersion string `json:"yield_version"` + } + if json.Unmarshal(b, &manifest) == nil { + if manifest.Version != 1 || manifest.YieldVersion == "" { + return nil, fmt.Errorf("skill.json version 1 requires a declared Yield version") + } + if e.SupervisorVersion == "" { + return nil, fmt.Errorf("skill requires Yield %s, but the running supervisor version is missing", manifest.YieldVersion) + } + if e.SupervisorVersion != "dev" && manifest.YieldVersion != e.SupervisorVersion { + return nil, fmt.Errorf("skill requires Yield %s, but the running supervisor is Yield %s", manifest.YieldVersion, e.SupervisorVersion) + } + supervisorVersion := e.SupervisorVersion + if supervisorVersion == "dev" { + supervisorVersion = manifest.YieldVersion + } + environment = append(environment, "YIELD_SUPERVISOR_VERSION="+supervisorVersion) + } + } + cmd.Env = environment cmd.Stderr = e.Stderr outBytes, err := cmd.Output() if err != nil { diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 3d1eead..59b76b1 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -381,11 +381,11 @@ func TestReplayDivergenceFailsLoudly(t *testing.T) { func TestExecuteRejectsAmbiguousProgramOutput(t *testing.T) { dir := t.TempDir() - manifest := `{"run":["sh","-c","printf '%s\\n' '{\"type\":\"request\",\"envelope\":{},\"terminal\":{\"status\":\"completed\"}}'"]}` + manifest := `{"version":1,"yield_version":"1.2.3","run":["sh","-c","printf '%s\\n' '{\"type\":\"request\",\"envelope\":{},\"terminal\":{\"status\":\"completed\"}}'"]}` if err := os.WriteFile(filepath.Join(dir, "skill.json"), []byte(manifest), 0o600); err != nil { t.Fatal(err) } - e := &Engine{SkillDir: dir, RunsDir: t.TempDir(), Stderr: os.Stderr} + e := &Engine{SkillDir: dir, RunsDir: t.TempDir(), Stderr: os.Stderr, SupervisorVersion: "1.2.3"} l, err := runlog.Create(e.RunsDir, "run_1") if err != nil { t.Fatal(err) @@ -402,6 +402,52 @@ func TestExecuteRejectsAmbiguousProgramOutput(t *testing.T) { } } +func TestExecuteBindsWorkflowToSupervisorVersion(t *testing.T) { + makeLog := func(t *testing.T, e *Engine) *runlog.Log { + t.Helper() + l, err := runlog.Create(e.RunsDir, "run_1") + if err != nil { + t.Fatal(err) + } + if _, err := l.Append(runlog.RunStarted, map[string]any{ + "run_id": "run_1", + "skill": protocol.SkillRef{Name: "identity", Digest: protocol.DigestBytes([]byte("identity"))}, + }); err != nil { + t.Fatal(err) + } + return l + } + makeEngine := func(t *testing.T, supervisor string) *Engine { + t.Helper() + dir := t.TempDir() + manifest := `{"version":1,"yield_version":"1.2.3","language":"typescript","run":["sh","-c","test \"$YIELD_SUPERVISOR_VERSION\" = 1.2.3 && printf '%s\\n' '{\"type\":\"terminal\",\"terminal\":{\"status\":\"completed\"}}'"]}` + if err := os.WriteFile(filepath.Join(dir, "skill.json"), []byte(manifest), 0o600); err != nil { + t.Fatal(err) + } + return &Engine{SkillDir: dir, RunsDir: t.TempDir(), Stderr: os.Stderr, SupervisorVersion: supervisor} + } + + t.Run("matching version reaches the SDK", func(t *testing.T) { + e := makeEngine(t, "1.2.3") + out, err := e.execute(makeLog(t, e), "run_1") + if err != nil || out.Terminal == nil || out.Terminal.Status != protocol.StatusCompleted { + t.Fatalf("matching supervisor = out %#v, err %v", out, err) + } + }) + for _, supervisor := range []string{"", "1.2.2", "1.2.4"} { + t.Run("rejects "+supervisor, func(t *testing.T) { + e := makeEngine(t, supervisor) + _, err := e.execute(makeLog(t, e), "run_1") + if err == nil || !strings.Contains(err.Error(), "1.2.3") { + t.Fatalf("supervisor %q error = %v", supervisor, err) + } + if supervisor != "" && !strings.Contains(err.Error(), supervisor) { + t.Fatalf("error must name both versions: %v", err) + } + }) + } +} + func TestFailedRequirementBlocksRun(t *testing.T) { e := testEngine(t, "skill-reqfail") p, err := e.StartRun(nil) diff --git a/packaging/assemble.mjs b/packaging/assemble.mjs index c784cdf..1add2a9 100644 --- a/packaging/assemble.mjs +++ b/packaging/assemble.mjs @@ -165,7 +165,9 @@ async function assemblePython({ version, binaries, output }) { } function rustDependency(target, version) { - return `[target.'cfg(all(target_os = "${target.rustOs}", target_arch = "${target.rustArch}"))'.dependencies]\n${rustPackage(target)} = { version = "=${version}" }\n` + return `[target.'cfg(all(target_os = "${target.rustOs}", target_arch = "${ + target.rustArch + }"))'.dependencies]\n${rustPackage(target)} = { version = "=${version}" }\n` } async function assembleRust({ version, binaries, output }, records) { @@ -188,7 +190,9 @@ async function assembleRust({ version, binaries, output }, records) { ) await writeFile( join(directory, "src/lib.rs"), - `pub const BYTES: &[u8] = include_bytes!("../runtime/${runtime}");\npub const SHA256: &str = "${runtimeByTarget.get(target.id).sha256}";\n`, + `pub const BYTES: &[u8] = include_bytes!("../runtime/${runtime}");\npub const SHA256: &str = "${ + runtimeByTarget.get(target.id).sha256 + }";\n`, ) } @@ -202,7 +206,9 @@ async function assembleRust({ version, binaries, output }, records) { /^version = ".*"/m, `version = "${version}"`, ) - cargo += `\n[[bin]]\nname = "yskill"\npath = "src/bin/yskill.rs"\n\n${targets.map((target) => rustDependency(target, version)).join("\n")}` + cargo += `\n[[bin]]\nname = "yskill"\npath = "src/bin/yskill.rs"\n\n${targets + .map((target) => rustDependency(target, version)) + .join("\n")}` await writeFile(join(main, "Cargo.toml"), cargo) await mkdir(join(main, "src/bin"), { recursive: true }) await cp(join(root, "packaging/rust-launcher.rs"), join(main, "src/bin/yskill.rs")) diff --git a/packaging/go-release.test.mjs b/packaging/go-release.test.mjs index cdd2c7b..a53983c 100644 --- a/packaging/go-release.test.mjs +++ b/packaging/go-release.test.mjs @@ -3,7 +3,9 @@ import assert from "node:assert/strict" import { modulePath, validateModuleReceipt, verifyGoRelease } from "./go-release.mjs" const sourceSha = "a".repeat(40) -const goPlatform = `${process.platform === "win32" ? "windows" : process.platform}/${process.arch === "x64" ? "amd64" : process.arch}` +const goPlatform = `${process.platform === "win32" ? "windows" : process.platform}/${ + process.arch === "x64" ? "amd64" : process.arch +}` function receipt(version = "1.2.3") { return { diff --git a/packaging/npm-release.mjs b/packaging/npm-release.mjs new file mode 100644 index 0000000..19236d7 --- /dev/null +++ b/packaging/npm-release.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto" +import { execFile } from "node:child_process" +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises" +import { join, resolve } from "node:path" +import { promisify } from "node:util" + +const run = promisify(execFile) + +function expect(value, message) { + if (!value) throw new Error(message) +} + +async function digest(path) { + return createHash("sha256") + .update(await readFile(path)) + .digest("hex") +} + +async function packageJSON(path) { + return JSON.parse(await readFile(join(path, "package.json"), "utf8")) +} + +async function tarListing(path) { + const { stdout } = await run("tar", ["-tzvf", path], { encoding: "utf8" }) + return stdout +} + +export async function verifyArchive(path, expected) { + const listing = await tarListing(path) + expect(listing.includes("package/package.json"), `${path}: archive is missing package.json`) + if (!expected.runtime) return + const runtime = `package/${expected.runtime}` + const line = listing.split("\n").find((value) => value.endsWith(` ${runtime}`)) + expect(line, `${path}: archive is missing ${runtime}`) + if (!expected.windows) + expect(/^-rwxr-xr-x\s/.test(line), `${path}: ${runtime} is not executable in the tar header`) +} + +export async function packReleaseUnit({ source, output, sourceSHA = "" }) { + expect(/^[0-9a-f]{40}$/.test(sourceSHA), "source SHA must be a full commit SHA") + await mkdir(output, { recursive: true }) + const directories = (await readdir(source, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(source, entry.name)) + .sort() + const archives = [] + for (const directory of directories) { + const manifest = await packageJSON(directory) + const { stdout } = await run("npm", ["pack", "--json", "--pack-destination", output], { + cwd: directory, + encoding: "utf8", + }) + const [packed] = JSON.parse(stdout) + expect( + packed?.name === manifest.name && packed?.version === manifest.version, + `${directory}: npm pack identity drift`, + ) + const archive = join(output, packed.filename) + const details = await stat(archive) + expect( + details.isFile() && details.size > 0, + `${directory}: npm pack did not create ${packed.filename}`, + ) + const runtime = manifest.files?.find((file) => file === "yskill" || file === "yskill.exe") + await verifyArchive(archive, { runtime, windows: runtime === "yskill.exe" }) + archives.push({ + name: manifest.name, + version: manifest.version, + file: packed.filename, + sha256: await digest(archive), + }) + } + const versions = new Set(archives.map((entry) => entry.version)) + expect(versions.size === 1, "npm release unit must contain exactly one version") + const release = { + schema_version: 1, + version: archives[0]?.version ?? "", + source_sha: sourceSHA, + archives, + } + await writeFile(join(output, "npm-release.json"), `${JSON.stringify(release, null, 2)}\n`) + return release +} + +function parseArgs(argv) { + const values = {} + for (let index = 0; index < argv.length; index += 2) + values[argv[index]?.replace(/^--/, "")] = argv[index + 1] + if (!values.source || !values.output || !values["source-sha"]) + throw new Error("--source, --output, and --source-sha are required") + return { + source: resolve(values.source), + output: resolve(values.output), + sourceSHA: values["source-sha"], + } +} + +if (process.argv[1] && import.meta.filename === process.argv[1]) { + packReleaseUnit(parseArgs(process.argv.slice(2))) + .then((result) => + console.log(`packed ${result.archives.length} immutable npm archives for ${result.version}`), + ) + .catch((error) => { + console.error(`npm-release: ${error.message}`) + process.exit(1) + }) +} diff --git a/packaging/npm-release.test.mjs b/packaging/npm-release.test.mjs new file mode 100644 index 0000000..467f3fe --- /dev/null +++ b/packaging/npm-release.test.mjs @@ -0,0 +1,40 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { packReleaseUnit } from "./npm-release.mjs" + +test("packs immutable archives and preserves runtime execute mode", async (t) => { + const root = await mkdtemp(join(tmpdir(), "yield-npm-release-")) + t.after(() => rm(root, { recursive: true, force: true })) + const source = join(root, "source") + const output = join(root, "output") + const runtime = join(source, "linux-arm64") + const sdk = join(source, "yield") + await mkdir(runtime, { recursive: true }) + await mkdir(sdk, { recursive: true }) + await writeFile( + join(runtime, "package.json"), + JSON.stringify({ + name: "@operatorstack/yield-linux-arm64", + version: "1.2.3", + files: ["yskill"], + }), + ) + await writeFile(join(runtime, "yskill"), "#!/bin/sh\necho ok\n", { mode: 0o755 }) + await writeFile( + join(sdk, "package.json"), + JSON.stringify({ name: "@operatorstack/yield", version: "1.2.3", files: ["index.js"] }), + ) + await writeFile(join(sdk, "index.js"), "export {};\n") + + const result = await packReleaseUnit({ source, output, sourceSHA: "a".repeat(40) }) + assert.equal(result.version, "1.2.3") + assert.equal(result.source_sha, "a".repeat(40)) + assert.deepEqual( + result.archives.map((entry) => entry.name), + ["@operatorstack/yield-linux-arm64", "@operatorstack/yield"], + ) + assert.match(result.archives[0].sha256, /^[0-9a-f]{64}$/) +}) diff --git a/packaging/package-contract.mjs b/packaging/package-contract.mjs new file mode 100644 index 0000000..b2ad3f3 --- /dev/null +++ b/packaging/package-contract.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto" +import { readFile, writeFile } from "node:fs/promises" +import { dirname, join, resolve } from "node:path" + +const languages = ["typescript", "python", "go", "rust"] +const targets = [ + "linux-amd64", + "linux-arm64", + "darwin-amd64", + "darwin-arm64", + "windows-amd64", + "windows-arm64", +] + +function expect(value, message) { + if (!value) throw new Error(message) +} + +async function json(path) { + return JSON.parse(await readFile(path, "utf8")) +} + +async function digest(path) { + return createHash("sha256") + .update(await readFile(path)) + .digest("hex") +} + +export async function createPackageContract({ + version, + sourceSHA, + npmReleasePath, + evaluationPath, + releaseURL, +}) { + expect(/^\d+\.\d+\.\d+$/.test(version), "version must be stable semver") + expect(/^[0-9a-f]{40}$/.test(sourceSHA), "source SHA must be a full commit SHA") + expect( + releaseURL === `https://github.com/operatorstack/yield/releases/tag/v${version}`, + "release URL must identify the matching Yield release", + ) + const npm = await json(npmReleasePath) + expect( + npm.schema_version === 1 && npm.version === version && npm.source_sha === sourceSHA, + "npm release manifest does not match the release version and source", + ) + expect( + npm.archives?.length === 8, + "npm release manifest must contain the SDK, initializer, and six runtimes", + ) + const names = new Set() + for (const archive of npm.archives) { + expect( + typeof archive.name === "string" && + typeof archive.file === "string" && + /^[0-9a-f]{64}$/.test(archive.sha256 ?? ""), + "npm release manifest contains an invalid archive", + ) + expect(!names.has(archive.name), "npm release manifest contains a duplicate package") + names.add(archive.name) + expect( + (await digest(join(dirname(npmReleasePath), archive.file))) === archive.sha256, + `npm archive digest does not match ${archive.file}`, + ) + } + const evaluation = await json(evaluationPath) + expect( + evaluation.schema_version === 2 && evaluation.status === "passed", + "evaluation result is not passing required evidence", + ) + expect( + /^[0-9a-f]{64}$/.test(evaluation.source_digest ?? ""), + "evaluation source digest is invalid", + ) + const contract = { + schema_version: 1, + version, + source_sha: sourceSHA, + languages, + targets, + current_and_previous_install_journeys: true, + package_history_complete: true, + npm_archives: npm.archives, + evidence_digest: createHash("sha256") + .update(await readFile(evaluationPath)) + .digest("hex"), + evaluation_source_digest: evaluation.source_digest, + release_url: releaseURL, + } + const contractDigest = createHash("sha256").update(JSON.stringify(contract)).digest("hex") + return { + contract, + promotion: { + schema_version: 1, + version, + source_sha: sourceSHA, + contract_digest: contractDigest, + evidence_digest: contract.evidence_digest, + release_url: releaseURL, + }, + } +} + +function args(argv) { + const values = {} + for (let index = 0; index < argv.length; index += 2) + values[argv[index]?.replace(/^--/, "")] = argv[index + 1] + for (const name of [ + "version", + "source-sha", + "npm-release", + "evaluation", + "release-url", + "output", + ]) + expect(values[name], `--${name} is required`) + return values +} + +if (process.argv[1] && import.meta.filename === process.argv[1]) { + const values = args(process.argv.slice(2)) + createPackageContract({ + version: values.version, + sourceSHA: values["source-sha"], + npmReleasePath: resolve(values["npm-release"]), + evaluationPath: resolve(values.evaluation), + releaseURL: values["release-url"], + }) + .then(async ({ contract, promotion }) => { + const output = resolve(values.output) + await writeFile( + `${output}/yield-package-contract.json`, + `${JSON.stringify(contract, null, 2)}\n`, + ) + await writeFile( + `${output}/yield-website-promotion.json`, + `${JSON.stringify(promotion, null, 2)}\n`, + ) + }) + .catch((error) => { + console.error(`package-contract: ${error.message}`) + process.exit(1) + }) +} diff --git a/packaging/package-contract.test.mjs b/packaging/package-contract.test.mjs new file mode 100644 index 0000000..80e7b80 --- /dev/null +++ b/packaging/package-contract.test.mjs @@ -0,0 +1,67 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { createHash } from "node:crypto" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { createPackageContract } from "./package-contract.mjs" + +test("binds package and evaluation evidence to one release", async (t) => { + const root = await mkdtemp(join(tmpdir(), "yield-contract-")) + t.after(() => rm(root, { recursive: true, force: true })) + const npm = join(root, "npm.json") + const evaluation = join(root, "evaluation.json") + const archives = await Promise.all( + Array.from({ length: 8 }, async (_, index) => { + const file = `pkg-${index}.tgz` + const bytes = Buffer.from(`archive-${index}`) + await writeFile(join(root, file), bytes) + return { + name: `pkg-${index}`, + file, + sha256: createHash("sha256").update(bytes).digest("hex"), + } + }), + ) + await writeFile( + npm, + JSON.stringify({ schema_version: 1, version: "1.2.3", source_sha: "c".repeat(40), archives }), + ) + await writeFile( + evaluation, + JSON.stringify({ schema_version: 2, status: "passed", source_digest: "b".repeat(64) }), + ) + const { contract, promotion } = await createPackageContract({ + version: "1.2.3", + sourceSHA: "c".repeat(40), + npmReleasePath: npm, + evaluationPath: evaluation, + releaseURL: "https://github.com/operatorstack/yield/releases/tag/v1.2.3", + }) + assert.equal(contract.version, "1.2.3") + assert.equal(contract.npm_archives.length, 8) + assert.equal(promotion.contract_digest.length, 64) + await assert.rejects( + () => + createPackageContract({ + version: "1.2.3", + sourceSHA: "c".repeat(40), + npmReleasePath: npm, + evaluationPath: evaluation, + releaseURL: "https://example.com", + }), + /release URL/, + ) + await writeFile(join(root, archives[0].file), "altered archive") + await assert.rejects( + () => + createPackageContract({ + version: "1.2.3", + sourceSHA: "c".repeat(40), + npmReleasePath: npm, + evaluationPath: evaluation, + releaseURL: "https://github.com/operatorstack/yield/releases/tag/v1.2.3", + }), + /digest/, + ) +}) diff --git a/packaging/pypi-release.mjs b/packaging/pypi-release.mjs index d34b5d5..2bf1ee0 100644 --- a/packaging/pypi-release.mjs +++ b/packaging/pypi-release.mjs @@ -121,7 +121,11 @@ async function main() { missing_count: result.missing.length, }) process.stdout.write( - `${JSON.stringify({ version, missing: result.missing.map(({ filename }) => filename) }, null, 2)}\n`, + `${JSON.stringify( + { version, missing: result.missing.map(({ filename }) => filename) }, + null, + 2, + )}\n`, ) return } diff --git a/scripts/check-release-control.mjs b/scripts/check-release-control.mjs index 2a6c0da..5fbd3ca 100644 --- a/scripts/check-release-control.mjs +++ b/scripts/check-release-control.mjs @@ -180,6 +180,14 @@ export async function checkReleaseControl(root = resolve(import.meta.dirname, ". raw["npm-publish.yml"].includes("@operatorstack/create-yield@${VERSION}"), "npm trusted publishing must include the initializer package", ) + expect( + raw["npm-publish.yml"].includes("packaging/npm-release.mjs --source"), + "npm packages must be transported as immutable tarballs", + ) + expect( + raw["npm-publish.yml"].includes('npm publish "dist/release-unit/npm/${file}"'), + "npm publisher must publish the verified archive, not a directory", + ) expect( raw["npm-publish.yml"].indexOf("Publish SDK and CLI") < raw["npm-publish.yml"].indexOf("Publish npm initializer"), @@ -268,6 +276,15 @@ export async function checkReleaseControl(root = resolve(import.meta.dirname, ". raw["release-finalize.yml"].includes("go-release.mjs"), "finalization must verify the public Go module and command install", ) + expect( + raw["release-finalize.yml"].includes("package-contract.mjs"), + "finalization must create the website package contract before release publication", + ) + expect( + raw["release-finalize.yml"].indexOf("gh release upload") < + raw["release-finalize.yml"].indexOf("--draft=false"), + "website contract assets must upload before the release is published", + ) expect( raw["release-finalize.yml"].includes('--source-sha "$SOURCE_SHA"'), "Go finalization must bind the module to the release source", diff --git a/scripts/readme.test.mjs b/scripts/readme.test.mjs index c815014..62c1a44 100644 --- a/scripts/readme.test.mjs +++ b/scripts/readme.test.mjs @@ -107,10 +107,13 @@ test("Go README presents a public five-step workflow", async () => { assert.ok(current > previous, `${heading} is missing or out of order`) previous = current } - assert.match(readme, /go install github\.com\/operatorstack\/yield\/cmd\/yskill@latest/) - assert.match(readme, /yskill init skills\/investigate/) - assert.match(readme, /yskill doctor skills\/investigate --test/) - assert.match(readme, /yskill register skills\/investigate/) + assert.match( + readme, + /GOBIN="\$PWD\/\.yield\/bin" go install github\.com\/operatorstack\/yield\/cmd\/yskill@latest/, + ) + assert.match(readme, /\.yield\/bin\/yskill init skills\/investigate/) + assert.match(readme, /\.yield\/bin\/yskill doctor skills\/investigate --root \. --test/) + assert.match(readme, /\.yield\/bin\/yskill register skills\/investigate --root \./) assert.match(readme, /^\/investigate$/m) assert.match(readme, /https:\/\/pkg\.go\.dev\/github\.com\/operatorstack\/yield\/sdk\/yield/) assert.match(readme, /https:\/\/proxy\.golang\.org/) @@ -135,10 +138,10 @@ test("Rust README presents a public five-step workflow", async () => { previous = current } - assert.match(readme, /cargo install yieldskill --locked/) - assert.match(readme, /yskill init skills\/data-migration/) - assert.match(readme, /yskill doctor skills\/data-migration --test/) - assert.match(readme, /yskill register skills\/data-migration/) + assert.match(readme, /cargo install yieldskill --root \.yield --locked/) + assert.match(readme, /\.yield\/bin\/yskill init skills\/data-migration/) + assert.match(readme, /\.yield\/bin\/yskill doctor skills\/data-migration --root \. --test/) + assert.match(readme, /\.yield\/bin\/yskill register skills\/data-migration --root \./) assert.match(readme, /^\/data-migration$/m) assert.match(readme, /https:\/\/crates\.io\/crates\/yieldskill/) assert.match(readme, /https:\/\/docs\.rs\/yieldskill/) @@ -278,7 +281,7 @@ test("README and quickstart use the public documentation and package registries" assert.match(pythonReadme, /python -m pip install yieldskill/) assert.match(pythonReadme, /https:\/\/pypi\.org\/project\/yieldskill\//) assert.doesNotMatch(pythonReadme, /get\.operatorstack\.systems\/pip/) - assert.match(rustReadme, /cargo install yieldskill --locked/) + assert.match(rustReadme, /cargo install yieldskill --root \.yield --locked/) assert.doesNotMatch(rustReadme, /get\.operatorstack\.systems\/cargo/) assert.match(goReadme, /go install github\.com\/operatorstack\/yield\/cmd\/yskill@latest/) assert.match( @@ -288,8 +291,8 @@ test("README and quickstart use the public documentation and package registries" const commands = [ "npm create @operatorstack/yield@latest", "uvx --from yieldskill yskill bootstrap --language python", - "yskill bootstrap --language rust", - "go run github.com/operatorstack/yield/cmd/yskill@latest bootstrap --language go", + ".yield/bin/yskill bootstrap --root . --language rust", + "go run github.com/operatorstack/yield/cmd/yskill@latest bootstrap --root . --language go", ] for (const command of commands) { assert.ok(readme.includes(command), `README is missing ${command}`) diff --git a/sdk/python/yieldskill/__init__.py b/sdk/python/yieldskill/__init__.py index d5b9040..3ea4a0d 100644 --- a/sdk/python/yieldskill/__init__.py +++ b/sdk/python/yieldskill/__init__.py @@ -249,10 +249,29 @@ def _emit(output: dict) -> None: sys.exit(0) +def _verify_supervisor_identity() -> None: + try: + with open("skill.json", "r", encoding="utf-8") as f: + manifest = json.load(f) + except (OSError, json.JSONDecodeError): + return + if manifest.get("version") != 1: + return + expected = manifest.get("yield_version") + actual = os.environ.get("YIELD_SUPERVISOR_VERSION") + if not expected or actual != expected: + print( + f"yield: supervisor version {actual or 'missing'} does not match workflow Yield version {expected or 'missing'}", + file=sys.stderr, + ) + sys.exit(2) + + def define_skill(program: Callable[[Context], Any]) -> None: """Run a skill program under the supervisor protocol. The program's return value is the run result; call ctx.blocked()/ctx.refused() for the honest terminals.""" + _verify_supervisor_identity() path = os.environ.get("YIELD_JOURNAL") if not path: print( diff --git a/sdk/rust/README.md b/sdk/rust/README.md index 9bb1c7f..4b94a8f 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -33,8 +33,8 @@ The crate and library names are both `yieldskill`. The installed command is ## Start with your coding agent ```bash -cargo install yieldskill --locked -yskill bootstrap --language rust +cargo install yieldskill --root .yield --locked +.yield/bin/yskill bootstrap --root . --language rust ``` Review and confirm the plan. Restart your coding agent. Then ask it to create @@ -57,8 +57,8 @@ Use Yield to convert my existing release SKILL.md into a tested skill workflow. Yield supports Rust on macOS, Linux, and Windows. Install the public crate: ```bash -cargo install yieldskill --locked -yskill --version +cargo install yieldskill --root .yield --locked +.yield/bin/yskill --version ``` The crate contains the matching `yskill` runtime for your platform. You do not @@ -69,7 +69,7 @@ need Go, Node.js, or a separate CLI download. Create a Rust workflow inside your repository: ```bash -yskill init skills/data-migration \ +.yield/bin/yskill init skills/data-migration \ --language rust \ --description "Dry-run, approve, apply, and verify a database migration." ``` @@ -161,7 +161,7 @@ Use deterministic fixture responses during tests. Save this as Then test the workflow: ```bash -yskill doctor skills/data-migration --test +.yield/bin/yskill doctor skills/data-migration --root . --test ``` Yield runs commands for real and supplies agent and user responses from the @@ -172,14 +172,14 @@ fixture. A successful test reaches `completed` without leaving a run journal. Registration lets installed coding agents discover the workflow: ```bash -yskill register skills/data-migration +.yield/bin/yskill register skills/data-migration --root . ``` Select the verified agents explicitly when you do not want automatic detection: ```bash -yskill register skills/data-migration \ +.yield/bin/yskill register skills/data-migration --root . \ --agent cursor,codex,claude-code ``` diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index 4cc9001..50f4510 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -20,6 +20,39 @@ use std::process::exit; pub const PROTOCOL: &str = "yield.v1"; +fn verify_supervisor_identity() { + let Ok(bytes) = std::fs::read("skill.json") else { + return; + }; + let Ok(manifest) = serde_json::from_slice::(&bytes) else { + return; + }; + if manifest.get("version").and_then(Value::as_i64) != Some(1) { + return; + } + let expected = manifest + .get("yield_version") + .and_then(Value::as_str) + .unwrap_or(""); + let actual = std::env::var("YIELD_SUPERVISOR_VERSION").unwrap_or_default(); + if expected.is_empty() || actual != expected { + eprintln!( + "yield: supervisor version {} does not match workflow Yield version {}", + if actual.is_empty() { + "missing" + } else { + &actual + }, + if expected.is_empty() { + "missing" + } else { + expected + } + ); + exit(2); + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillRef { pub name: String, @@ -349,6 +382,7 @@ impl Context { /// value is the run result; return `Err(ctx.blocked(...))` or /// `Err(ctx.refused(...))` for the honest terminals. pub fn define_skill(program: fn(&mut Context) -> SkillResult) -> ! { + verify_supervisor_identity(); let path = match std::env::var("YIELD_JOURNAL") { Ok(p) => p, Err(_) => { diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index e4a5b0f..63c4c58 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -71,6 +71,20 @@ interface Journal { entries?: { request: Request; response: ResponseEnvelope }[]; } +function verifySupervisorIdentity(): void { + let manifest: { version?: number; yield_version?: string }; + try { + manifest = JSON.parse(readFileSync("skill.json", "utf8")) as { version?: number; yield_version?: string }; + } catch { + return; + } + if (manifest.version !== 1) return; + if (!manifest.yield_version || env.YIELD_SUPERVISOR_VERSION !== manifest.yield_version) { + stderr.write(`yield: supervisor version ${env.YIELD_SUPERVISOR_VERSION || "missing"} does not match workflow Yield version ${manifest.yield_version || "missing"}\n`); + exit(2); + } +} + type ProgramOutput = | { type: "request"; envelope: RequestEnvelope; requirements?: Requirement[] } | { @@ -302,6 +316,7 @@ function emit(output: ProgramOutput): never { * the honest terminals. */ export function defineSkill(program: (ctx: Context) => unknown): void { + verifySupervisorIdentity(); const path = env.YIELD_JOURNAL; if (!path) { stderr.write( diff --git a/sdk/yield/README.md b/sdk/yield/README.md index 9c74f72..36b8ecd 100644 --- a/sdk/yield/README.md +++ b/sdk/yield/README.md @@ -32,7 +32,7 @@ The Go module is `github.com/operatorstack/yield`. Import the SDK as ## Start with your coding agent ```bash -go run github.com/operatorstack/yield/cmd/yskill@latest bootstrap --language go +go run github.com/operatorstack/yield/cmd/yskill@latest bootstrap --root . --language go ``` Review and confirm the plan. Restart your coding agent. Then ask it to create @@ -55,8 +55,9 @@ Use Yield to convert my existing release SKILL.md into a tested skill workflow. Yield supports Go on macOS, Linux, and Windows. Install the public command: ```bash -go install github.com/operatorstack/yield/cmd/yskill@latest -yskill --version +mkdir -p .yield/bin +GOBIN="$PWD/.yield/bin" go install github.com/operatorstack/yield/cmd/yskill@latest +.yield/bin/yskill --version ``` Go downloads the tagged module through @@ -68,7 +69,7 @@ registry account or private package source. Create a Go workflow inside your repository: ```bash -yskill init skills/investigate \ +.yield/bin/yskill init skills/investigate \ --language go \ --description "Collect failure evidence, test hypotheses, and report the cause." ``` @@ -229,7 +230,7 @@ Use deterministic responses during tests. Save this as Then test the workflow: ```bash -yskill doctor skills/investigate --test +.yield/bin/yskill doctor skills/investigate --root . --test ``` Yield runs commands for real and supplies agent responses from the fixture. A @@ -240,14 +241,14 @@ successful test reaches `completed` without leaving a run journal. Registration lets installed coding agents discover the workflow: ```bash -yskill register skills/investigate +.yield/bin/yskill register skills/investigate --root . ``` Select the verified agents explicitly when you do not want automatic detection: ```bash -yskill register skills/investigate \ +.yield/bin/yskill register skills/investigate --root . \ --agent cursor,codex,claude-code ``` diff --git a/sdk/yield/yield.go b/sdk/yield/yield.go index f0fead7..6d182c2 100644 --- a/sdk/yield/yield.go +++ b/sdk/yield/yield.go @@ -23,6 +23,31 @@ import ( // the supervisor (yskill) hands to the subprocess. const EnvJournal = "YIELD_JOURNAL" +func verifySupervisorIdentity() { + b, err := os.ReadFile("skill.json") + if err != nil { + return + } + var manifest struct { + Version int `json:"version"` + YieldVersion string `json:"yield_version"` + } + if json.Unmarshal(b, &manifest) != nil || manifest.Version != 1 { + return + } + actual := os.Getenv("YIELD_SUPERVISOR_VERSION") + if manifest.YieldVersion == "" || actual != manifest.YieldVersion { + if actual == "" { + actual = "missing" + } + if manifest.YieldVersion == "" { + manifest.YieldVersion = "missing" + } + fmt.Fprintf(os.Stderr, "yield: supervisor version %s does not match workflow Yield version %s\n", actual, manifest.YieldVersion) + os.Exit(2) + } +} + // Context carries the replay cursor and the primitives. type Context struct { journal protocol.Journal @@ -173,6 +198,7 @@ func (c *Context) step(req protocol.Request) protocol.ResponseEnvelope { // journal named by YIELD_JOURNAL, executes the program, and emits exactly // one ProgramOutput on stdout. func Main(program func(*Context) (Outcome, error)) { + verifySupervisorIdentity() path := os.Getenv(EnvJournal) if path == "" { fmt.Fprintln(os.Stderr, "yield: YIELD_JOURNAL is not set; this program is run by yskill, not directly") diff --git a/skills/release-yield/skill.json b/skills/release-yield/skill.json index e54de2c..292b989 100644 --- a/skills/release-yield/skill.json +++ b/skills/release-yield/skill.json @@ -1,5 +1,6 @@ { "version": 1, + "yield_version": "0.1.38", "language": "typescript", "run": ["node", "main.ts"] } diff --git a/skills/release-yield/src/release-controller.mjs b/skills/release-yield/src/release-controller.mjs index 4eda28c..cda1f8d 100644 --- a/skills/release-yield/src/release-controller.mjs +++ b/skills/release-yield/src/release-controller.mjs @@ -141,7 +141,9 @@ function approve(runID, deployments, expected) { ) } catch { throw new Blocked( - `GitHub refused approval for ${names.join(", ")}; approve the environments at the workflow run`, + `GitHub refused approval for ${names.join( + ", ", + )}; approve the environments at the workflow run`, ) } return names @@ -378,7 +380,10 @@ async function main() { } catch (error) { const status = error instanceof Blocked ? "blocked" : "failed" process.stdout.write( - `${JSON.stringify({ status, reason: error instanceof Error ? error.message : String(error) })}\n`, + `${JSON.stringify({ + status, + reason: error instanceof Error ? error.message : String(error), + })}\n`, ) } }