From a26ed5a4a814e2524c5d7c2836b977efe8364f33 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Fri, 7 Aug 2026 22:43:22 +0100 Subject: [PATCH 1/2] Publish Rust SDK on crates.io --- .changeset/rust-crates-public-release.md | 5 + .github/workflows/npm-publish.yml | 93 ++++++++- .github/workflows/release-finalize.yml | 5 + README.md | 2 + cmd/yskill/agents.go | 2 +- cmd/yskill/main_test.go | 5 +- cmd/yskill/scaffold.go | 3 +- examples/data-migration/src/main.rs | 2 + packaging/assemble.mjs | 9 +- packaging/assemble.test.mjs | 28 ++- packaging/cargo-index.mjs | 48 ----- packaging/cargo-index.test.mjs | 16 -- packaging/crates-release.mjs | 148 ++++++++++++++ packaging/crates-release.test.mjs | 58 ++++++ scripts/check-release-control.mjs | 7 + scripts/readme.test.mjs | 59 +++++- sdk/rust/Cargo.toml | 5 + sdk/rust/README.md | 236 +++++++++++++++++++++++ 18 files changed, 658 insertions(+), 73 deletions(-) create mode 100644 .changeset/rust-crates-public-release.md delete mode 100644 packaging/cargo-index.mjs delete mode 100644 packaging/cargo-index.test.mjs create mode 100644 packaging/crates-release.mjs create mode 100644 packaging/crates-release.test.mjs create mode 100644 sdk/rust/README.md diff --git a/.changeset/rust-crates-public-release.md b/.changeset/rust-crates-public-release.md new file mode 100644 index 0000000..ce6335b --- /dev/null +++ b/.changeset/rust-crates-public-release.md @@ -0,0 +1,5 @@ +--- +"@operatorstack/yield": patch +--- + +Publish the Rust SDK and platform runtime crates on crates.io with a Rust-first onboarding guide and trusted release controls. diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 453025a..d42dfe0 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -132,6 +132,23 @@ jobs: python -m build --wheel --no-isolation --outdir "$GITHUB_WORKSPACE/dist/pypi" "$directory" done node packaging/pypi-release.mjs inspect --version "$VERSION" --dist dist/pypi + - name: Build Rust crates + if: needs.resolve.outputs.channel == 'stable' + env: + VERSION: ${{ needs.resolve.outputs.version }} + shell: bash + run: | + set -euo pipefail + node packaging/crates-release.mjs inspect --version "$VERSION" --rust dist/packages/rust + mkdir -p dist/crates + for directory in dist/packages/rust/runtime/*; do + cargo package --manifest-path "$directory/Cargo.toml" + name="$(sed -n 's/^name = "\([^"]*\)"/\1/p' "$directory/Cargo.toml" | head -n 1)" + cp "$directory/target/package/${name}-${VERSION}.crate" dist/crates/ + done + (cd dist/packages/rust && cargo package --manifest-path yieldskill/Cargo.toml) + cp "dist/packages/rust/yieldskill/target/package/yieldskill-${VERSION}.crate" dist/crates/ + test "$(find dist/crates -maxdepth 1 -name '*.crate' | wc -l | tr -d ' ')" = 7 - name: Inspect npm tarballs shell: bash run: | @@ -146,7 +163,11 @@ jobs: 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; fi + if [[ "$CHANNEL" == stable ]]; then + cp -R dist/pypi dist/release-unit/pypi + cp -R dist/packages/rust dist/release-unit/rust + cp -R dist/crates dist/release-unit/crates + fi - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: packages-${{ needs.resolve.outputs.version }}-${{ needs.resolve.outputs.source_sha }} @@ -261,3 +282,73 @@ jobs: --dist dist/release-unit/pypi --attempts 12 --delay-ms 10000 + + crates: + needs: [resolve, build] + if: needs.resolve.outputs.channel == 'stable' + runs-on: ubuntu-latest + environment: crates-production + permissions: + contents: read + id-token: write + env: + CRATES_BOOTSTRAP_TOKEN: ${{ secrets.CRATES_BOOTSTRAP_TOKEN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + ref: ${{ needs.resolve.outputs.source_sha }} + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: packages-${{ needs.resolve.outputs.version }}-${{ needs.resolve.outputs.source_sha }} + path: dist/release-unit + - id: auth + name: Request a short-lived crates.io token + if: env.CRATES_BOOTSTRAP_TOKEN == '' + uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 + - name: Publish complete Rust release unit + env: + CARGO_REGISTRY_TOKEN: ${{ env.CRATES_BOOTSTRAP_TOKEN || steps.auth.outputs.token }} + VERSION: ${{ needs.resolve.outputs.version }} + shell: bash + run: | + set -euo pipefail + publish_if_missing() { + local directory="$1" + local name + name="$(sed -n 's/^name = "\([^"]*\)"/\1/p' "$directory/Cargo.toml" | head -n 1)" + local archive="dist/release-unit/crates/${name}-${VERSION}.crate" + local state + state="$(node packaging/crates-release.mjs status --version "$VERSION" --name "$name" --archive "$archive")" + if [[ "$state" == matched ]]; then + echo "${name}@${VERSION} already exists with the release-unit checksum" + else + test "$state" = missing + (cd "$directory" && cargo publish) + fi + } + + for directory in dist/release-unit/rust/runtime/*; do + publish_if_missing "$directory" + done + + for attempt in {1..18}; do + missing=0 + for directory in dist/release-unit/rust/runtime/*; do + name="$(sed -n 's/^name = "\([^"]*\)"/\1/p' "$directory/Cargo.toml" | head -n 1)" + archive="dist/release-unit/crates/${name}-${VERSION}.crate" + if [[ "$(node packaging/crates-release.mjs status --version "$VERSION" --name "$name" --archive "$archive")" != matched ]]; then + missing=1 + fi + done + if [[ "$missing" == 0 ]]; then break; fi + test "$attempt" -lt 18 + sleep 10 + done + + publish_if_missing dist/release-unit/rust/yieldskill + node packaging/crates-release.mjs verify \ + --version "$VERSION" \ + --archives dist/release-unit/crates \ + --attempts 18 \ + --delay-ms 10000 diff --git a/.github/workflows/release-finalize.yml b/.github/workflows/release-finalize.yml index a9e1daa..2ad013d 100644 --- a/.github/workflows/release-finalize.yml +++ b/.github/workflows/release-finalize.yml @@ -104,5 +104,10 @@ jobs: --dist "$RUNNER_TEMP/release-unit/pypi" \ --attempts 3 \ --delay-ms 10000 + node packaging/crates-release.mjs verify \ + --version "$version" \ + --archives "$RUNNER_TEMP/release-unit/crates" \ + --attempts 3 \ + --delay-ms 10000 test "$(git rev-list -n 1 "$TAG")" = "$SOURCE_SHA" gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false diff --git a/README.md b/README.md index fe34587..a48e6bb 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ npm version PyPI version + crates.io version Build status MIT license @@ -27,6 +28,7 @@ npm · PyPI · + crates.io · GitHub

diff --git a/cmd/yskill/agents.go b/cmd/yskill/agents.go index d58a759..533aa15 100644 --- a/cmd/yskill/agents.go +++ b/cmd/yskill/agents.go @@ -682,7 +682,7 @@ func localRuntimeInstallCommand(language, expected string) string { } return fmt.Sprintf(`mkdir -p .yield/bin && GOBIN="$PWD/.yield/bin" GOPROXY=https://get.operatorstack.systems/go,direct go install github.com/operatorstack/yield/cmd/yskill@v%s`, expected) case "rust": - return fmt.Sprintf(`cargo install yieldskill@%s --root .yield --index sparse+https://get.operatorstack.systems/cargo/index/ --locked`, expected) + return fmt.Sprintf(`cargo install yieldskill@%s --root .yield --locked`, expected) default: return "install the matching Yield package" } diff --git a/cmd/yskill/main_test.go b/cmd/yskill/main_test.go index 8491daa..88b9cee 100644 --- a/cmd/yskill/main_test.go +++ b/cmd/yskill/main_test.go @@ -190,7 +190,7 @@ func TestScaffoldSkillWritesLanguageSpecificEntrypoints(t *testing.T) { {"typescript", []string{"main.ts", "package.json", "skill.json"}, "npm exec -- yskill run .", `"@operatorstack/yield": "0.1.9"`}, {"python", []string{"main.py", "requirements.txt", "skill.json"}, "python -m yieldskill run .", "yieldskill==0.1.9"}, {"go", []string{"main.go", "go.mod", "skill.json"}, "yskill run .", "github.com/operatorstack/yield v0.1.9"}, - {"rust", []string{"src/main.rs", "Cargo.toml", ".cargo/config.toml", "skill.json"}, "yskill run .", `version = "=0.1.9"`}, + {"rust", []string{"src/main.rs", "Cargo.toml", "skill.json"}, "yskill run .", `version = "=0.1.9"`}, } for _, tt := range tests { t.Run(tt.language, func(t *testing.T) { @@ -243,6 +243,9 @@ func TestScaffoldSkillWritesLanguageSpecificEntrypoints(t *testing.T) { if tt.language == "python" && strings.Contains(manifest, "--index-url") { t.Fatalf("public Python scaffold contains a private package index:\n%s", manifest) } + if tt.language == "rust" && strings.Contains(manifest, "registry =") { + t.Fatalf("public Rust scaffold contains a private package registry:\n%s", manifest) + } }) } if tidyCalls != 1 { diff --git a/cmd/yskill/scaffold.go b/cmd/yskill/scaffold.go index 77503f6..6785ab9 100644 --- a/cmd/yskill/scaffold.go +++ b/cmd/yskill/scaffold.go @@ -159,8 +159,7 @@ func scaffoldFiles(name, language, sdkPath string) map[string]string { } case "rust": return map[string]string{ - ".cargo/config.toml": "[registries.operatorstack]\nindex = \"sparse+https://get.operatorstack.systems/cargo/index/\"\n", - "Cargo.toml": fmt.Sprintf("[package]\nname = %q\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nyieldskill = { version = \"=%s\", registry = \"operatorstack\" }\nserde_json = \"1\"\n", name, v), + "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), } diff --git a/examples/data-migration/src/main.rs b/examples/data-migration/src/main.rs index efe9f79..beedff7 100644 --- a/examples/data-migration/src/main.rs +++ b/examples/data-migration/src/main.rs @@ -2,6 +2,7 @@ // dry-run, show the diff, human approval, apply, verify. The append-only // run log is the audit trail; every irreversible action has a recorded, // approved request before it. +// README_EXAMPLE_START use serde_json::json; use yieldskill::{define_skill, Context, SkillResult}; @@ -56,3 +57,4 @@ fn program(ctx: &mut Context) -> SkillResult { fn main() { define_skill(program); } +// README_EXAMPLE_END diff --git a/packaging/assemble.mjs b/packaging/assemble.mjs index 865a709..7462d33 100644 --- a/packaging/assemble.mjs +++ b/packaging/assemble.mjs @@ -103,7 +103,7 @@ 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}", registry = "operatorstack" }\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) { @@ -115,17 +115,22 @@ async function assembleRust({ version, binaries, output }, records) { const runtime = target.goos === "windows" ? "yskill.exe" : "yskill"; await mkdir(join(directory, "src"), { recursive: true }); await copyBinary(join(binaries, binaryName(target)), join(directory, "runtime", runtime), false); - await writeFile(join(directory, "Cargo.toml"), `[package]\nname = "${name}"\nversion = "${version}"\nedition = "2021"\nlicense = "MIT"\ndescription = "Internal Yield runtime for ${target.id}"\nrepository = "https://github.com/operatorstack/yield"\ninclude = ["src/lib.rs", "runtime/${runtime}"]\n\n[lib]\npath = "src/lib.rs"\n`); + await cp(join(root, "LICENSE"), join(directory, "LICENSE")); + await writeFile(join(directory, "README.md"), `# ${name}\n\nPlatform runtime support for [Yield](https://crates.io/crates/yieldskill) on ${target.id}.\n\nThis crate is installed automatically by \`yieldskill\`. Do not add it directly.\n`); + await writeFile(join(directory, "Cargo.toml"), `[package]\nname = "${name}"\nversion = "${version}"\nedition = "2021"\nlicense = "MIT"\ndescription = "Yield runtime support for ${target.id}."\nrepository = "https://github.com/operatorstack/yield"\nhomepage = "https://yield.operatorstack.systems/"\nreadme = "README.md"\ninclude = ["src/lib.rs", "runtime/${runtime}", "README.md", "LICENSE"]\n\n[lib]\npath = "src/lib.rs"\n`); 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`); } const main = join(rust, "yieldskill"); await cp(join(root, "sdk/rust"), main, { recursive: true, filter: (source) => !source.includes("/target") }); + await cp(join(root, "LICENSE"), join(main, "LICENSE")); let cargo = (await readFile(join(main, "Cargo.toml"), "utf8")).replace(/^version = ".*"/m, `version = "${version}"`); 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")); + await mkdir(join(rust, ".cargo"), { recursive: true }); + await writeFile(join(rust, ".cargo/config.toml"), `[patch.crates-io]\n${targets.map((target) => `${rustPackage(target)} = { path = "runtime/${target.id}" }`).join("\n")}\n`); } export async function assemble(options) { diff --git a/packaging/assemble.test.mjs b/packaging/assemble.test.mjs index 8548a3d..4ecbec1 100644 --- a/packaging/assemble.test.mjs +++ b/packaging/assemble.test.mjs @@ -4,7 +4,7 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { assemble, isPackageVersion } from "./assemble.mjs"; -import { binaryName, npmPackage, targets } from "./targets.mjs"; +import { binaryName, npmPackage, rustPackage, targets } from "./targets.mjs"; const homepage = "https://yield.operatorstack.systems/"; @@ -75,5 +75,31 @@ test("assembles one public npm package and six matching npm and Python runtimes" await readFile(join(pythonRoot, "yieldskill/_runtime", pythonRuntime), "utf8"), `runtime:${target.id}`, ); + + const rustRoot = join(output, `rust/runtime/${target.id}`); + const rustManifest = await readFile(join(rustRoot, "Cargo.toml"), "utf8"); + assert.match(rustManifest, new RegExp(`name = "${rustPackage(target)}"`)); + assert.match(rustManifest, /version = "1\.2\.3"/); + assert.match(rustManifest, /readme = "README\.md"/); + assert.doesNotMatch(rustManifest, /registry\s*=/); + assert.match(await readFile(join(rustRoot, "README.md"), "utf8"), /installed automatically by `yieldskill`/); + assert.match(await readFile(join(rustRoot, "LICENSE"), "utf8"), /MIT License/); + } + + const rustMain = join(output, "rust/yieldskill"); + const rustMainManifest = await readFile(join(rustMain, "Cargo.toml"), "utf8"); + assert.match(rustMainManifest, /name = "yieldskill"/); + assert.match(rustMainManifest, /version = "1\.2\.3"/); + assert.doesNotMatch(rustMainManifest, /registry\s*=/); + for (const target of targets) { + assert.match(rustMainManifest, new RegExp(`${rustPackage(target)} = \\{ version = "=1\\.2\\.3" \\}`)); + } + const rustReadme = await readFile(join(rustMain, "README.md"), "utf8"); + assert.match(rustReadme, /crates\.io\/crates\/yieldskill/); + assert.doesNotMatch(rustReadme, /npmjs\.com|pypi\.org/); + assert.match(await readFile(join(rustMain, "LICENSE"), "utf8"), /MIT License/); + const rustPatch = await readFile(join(output, "rust/.cargo/config.toml"), "utf8"); + for (const target of targets) { + assert.match(rustPatch, new RegExp(`${rustPackage(target)} = \\{ path = "runtime/${target.id}" \\}`)); } }); diff --git a/packaging/cargo-index.mjs b/packaging/cargo-index.mjs deleted file mode 100644 index d2f7dcf..0000000 --- a/packaging/cargo-index.mjs +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env node -import { readFile, writeFile } from "node:fs/promises"; -import process from "node:process"; -import { resolve } from "node:path"; -import { rustPackage, targets } from "./targets.mjs"; - -const cratesIo = "https://github.com/rust-lang/crates.io-index"; -const operatorstack = "sparse+https://get.operatorstack.systems/cargo/index/"; - -function dependency(name, req, { features = [], target = null, registry = cratesIo } = {}) { - return { name, req, features, optional: false, default_features: true, target, kind: "normal", registry }; -} - -export function record(name, version, checksum) { - const deps = name === "yieldskill" ? [ - dependency("hex", "^0.4"), - dependency("serde", "^1", { features: ["derive"] }), - dependency("serde_json", "^1"), - dependency("sha2", "^0.10"), - ...targets.map((target) => dependency(rustPackage(target), `=${version}`, { - target: `cfg(all(target_os = "${target.rustOs}", target_arch = "${target.rustArch}"))`, - registry: operatorstack, - })), - ] : []; - return { name, vers: version, deps, cksum: checksum, features: {}, yanked: false, links: null }; -} - -function args(argv) { - const values = {}; - for (let index = 0; index < argv.length; index += 2) values[argv[index]?.replace(/^--/, "")] = argv[index + 1]; - if (!values.name || !values.version || !/^[a-f0-9]{64}$/.test(values.checksum ?? "") || !values.output) { - throw new Error("--name, --version, --checksum, and --output are required"); - } - return values; -} - -async function main() { - const values = args(process.argv.slice(2)); - const output = resolve(values.output); - const current = await readFile(output, "utf8").catch(() => ""); - const lines = current.split("\n").filter(Boolean).filter((line) => JSON.parse(line).vers !== values.version); - lines.push(JSON.stringify(record(values.name, values.version, values.checksum))); - await writeFile(output, `${lines.join("\n")}\n`); -} - -if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { - main().catch((error) => { console.error(`cargo-index: ${error.message}`); process.exit(1); }); -} diff --git a/packaging/cargo-index.test.mjs b/packaging/cargo-index.test.mjs deleted file mode 100644 index b41f1fb..0000000 --- a/packaging/cargo-index.test.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { record } from "./cargo-index.mjs"; - -test("runtime crate index records have no dependencies", () => { - assert.deepEqual(record("yieldskill-runtime-linux-amd64", "1.2.3", "a".repeat(64)).deps, []); -}); - -test("public crate pins all six runtime packages to the same version", () => { - const result = record("yieldskill", "1.2.3", "b".repeat(64)); - const runtimes = result.deps.filter((dep) => dep.name.startsWith("yieldskill-runtime-")); - assert.equal(runtimes.length, 6); - assert.ok(runtimes.every((dep) => dep.req === "=1.2.3")); - assert.ok(runtimes.every((dep) => dep.registry.includes("get.operatorstack.systems"))); - assert.equal(new Set(runtimes.map((dep) => dep.target)).size, 6); -}); diff --git a/packaging/crates-release.mjs b/packaging/crates-release.mjs new file mode 100644 index 0000000..959246c --- /dev/null +++ b/packaging/crates-release.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { readdir, readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import process from "node:process"; +import { rustPackage, targets } from "./targets.mjs"; + +export const crateNames = [...targets.map(rustPackage), "yieldskill"]; + +export function indexPath(name) { + const normalized = name.toLowerCase(); + if (normalized.length === 1) return `1/${normalized}`; + if (normalized.length === 2) return `2/${normalized}`; + if (normalized.length === 3) return `3/${normalized[0]}/${normalized}`; + return `${normalized.slice(0, 2)}/${normalized.slice(2, 4)}/${normalized}`; +} + +export async function registryRecord(name, version, fetchImpl = fetch) { + const response = await fetchImpl(`https://index.crates.io/${indexPath(name)}`, { + headers: { "User-Agent": "operatorstack-yield-release/1" }, + }); + if (response.status === 404) return null; + if (!response.ok) throw new Error(`crates.io index returned HTTP ${response.status} for ${name}`); + for (const line of (await response.text()).split("\n").filter(Boolean)) { + const record = JSON.parse(line); + if (record.vers === version) return record; + } + return null; +} + +function parseArgs(argv) { + const values = {}; + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index]; + if (!key?.startsWith("--") || argv[index + 1] === undefined) throw new Error(`invalid argument ${key ?? ""}`); + values[key.slice(2)] = argv[index + 1]; + } + if (!/^\d+\.\d+\.\d+$/.test(values.version ?? "")) throw new Error("--version must be stable semver"); + return values; +} + +function field(manifest, name) { + return manifest.match(new RegExp(`^${name}\\s*=\\s*"([^"]+)"`, "m"))?.[1] ?? ""; +} + +export async function inspectRelease({ version, rust }) { + const root = resolve(rust); + const manifests = []; + for (const target of targets) manifests.push(join(root, "runtime", target.id, "Cargo.toml")); + manifests.push(join(root, "yieldskill", "Cargo.toml")); + const seen = []; + for (const manifestPath of manifests) { + const manifest = await readFile(manifestPath, "utf8"); + const name = field(manifest, "name"); + if (!crateNames.includes(name)) throw new Error(`${manifestPath}: unexpected crate ${name}`); + if (field(manifest, "version") !== version) throw new Error(`${name}: expected version ${version}`); + if (field(manifest, "license") !== "MIT") throw new Error(`${name}: MIT license metadata is required`); + if (field(manifest, "repository") !== "https://github.com/operatorstack/yield") throw new Error(`${name}: canonical repository is required`); + if (/registry\s*=|get\.operatorstack\.systems/.test(manifest)) throw new Error(`${name}: private registry configuration is forbidden`); + if (name === "yieldskill") { + for (const runtime of targets.map(rustPackage)) { + const escaped = runtime.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const dependency = new RegExp(`^${escaped}\\s*=\\s*\\{\\s*version\\s*=\\s*"=${version.replace(/\./g, "\\.")}"\\s*\\}$`, "m"); + if (!dependency.test(manifest)) throw new Error(`${name}: ${runtime} must be pinned to ${version} on crates.io`); + } + } + const readme = field(manifest, "readme"); + if (readme) await readFile(join(resolve(manifestPath, ".."), readme), "utf8"); + seen.push(name); + } + if (new Set(seen).size !== crateNames.length) throw new Error("Rust release unit has duplicate or missing crates"); + return seen; +} + +async function sha256(path) { + return createHash("sha256").update(await readFile(path)).digest("hex"); +} + +function archiveName(name, version) { + return `${name}-${version}.crate`; +} + +async function localArchives(directory, version) { + const names = new Set(await readdir(directory)); + const result = new Map(); + for (const name of crateNames) { + const file = archiveName(name, version); + if (!names.has(file)) throw new Error(`missing crate archive ${file}`); + result.set(name, resolve(directory, file)); + } + if (names.size !== crateNames.length) throw new Error("crate archive directory contains unexpected files"); + return result; +} + +export async function status({ name, version, archive, fetchImpl = fetch }) { + if (!crateNames.includes(name)) throw new Error(`unexpected crate ${name}`); + const record = await registryRecord(name, version, fetchImpl); + if (!record) return "missing"; + const expected = await sha256(archive); + if (record.cksum !== expected) throw new Error(`${name}@${version}: published checksum does not match the release unit`); + return "matched"; +} + +export async function verifyRelease({ version, archives, attempts = 1, delayMs = 0, fetchImpl = fetch }) { + const local = await localArchives(resolve(archives), version); + let missing = []; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + missing = []; + for (const [name, archive] of local) { + if (await status({ name, version, archive, fetchImpl }) === "missing") missing.push(name); + } + if (!missing.length) return crateNames; + if (attempt < attempts) await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs)); + } + throw new Error(`crates.io is missing ${missing.join(", ")} at ${version}`); +} + +async function main() { + const [command, ...argv] = process.argv.slice(2); + const values = parseArgs(argv); + if (command === "inspect") { + if (!values.rust) throw new Error("inspect requires --rust"); + const names = await inspectRelease({ version: values.version, rust: values.rust }); + console.log(`crates-release: inspected ${names.length} crates at ${values.version}`); + return; + } + if (command === "status") { + if (!values.name || !values.archive) throw new Error("status requires --name and --archive"); + console.log(await status({ name: values.name, version: values.version, archive: resolve(values.archive) })); + return; + } + if (command === "verify") { + if (!values.archives) throw new Error("verify requires --archives"); + const names = await verifyRelease({ + version: values.version, + archives: values.archives, + attempts: Number(values.attempts ?? 1), + delayMs: Number(values["delay-ms"] ?? 0), + }); + console.log(`crates-release: verified ${names.length} crates at ${values.version}`); + return; + } + throw new Error(`unknown command ${command ?? ""}`); +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + main().catch((error) => { console.error(`crates-release: ${error.message}`); process.exit(1); }); +} diff --git a/packaging/crates-release.test.mjs b/packaging/crates-release.test.mjs new file mode 100644 index 0000000..9583c2d --- /dev/null +++ b/packaging/crates-release.test.mjs @@ -0,0 +1,58 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { crateNames, indexPath, registryRecord, status, verifyRelease } from "./crates-release.mjs"; + +function response(statusCode, text = "") { + return { status: statusCode, ok: statusCode >= 200 && statusCode < 300, text: async () => text }; +} + +test("maps public crate names to sparse index paths", () => { + assert.equal(indexPath("yieldskill"), "yi/el/yieldskill"); + assert.equal(indexPath("yieldskill-runtime-linux-amd64"), "yi/el/yieldskill-runtime-linux-amd64"); +}); + +test("distinguishes an absent version from an absent crate", async () => { + assert.equal(await registryRecord("yieldskill", "1.2.3", async () => response(404)), null); + assert.equal(await registryRecord("yieldskill", "1.2.3", async () => response(200, '{"vers":"1.2.2"}\n')), null); +}); + +test("refuses an immutable version with a different checksum", async (t) => { + const root = await mkdtemp(join(tmpdir(), "yield-crate-status-")); + t.after(() => rm(root, { recursive: true, force: true })); + const archive = join(root, "yieldskill-1.2.3.crate"); + await writeFile(archive, "release-unit"); + await assert.rejects( + status({ + name: "yieldskill", + version: "1.2.3", + archive, + fetchImpl: async () => response(200, `${JSON.stringify({ vers: "1.2.3", cksum: "0".repeat(64) })}\n`), + }), + /checksum does not match/, + ); +}); + +test("verifies the complete seven-crate release unit", async (t) => { + const root = await mkdtemp(join(tmpdir(), "yield-crates-verify-")); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(root, { recursive: true }); + const checksums = new Map(); + for (const name of crateNames) { + const body = `archive:${name}`; + await writeFile(join(root, `${name}-1.2.3.crate`), body); + checksums.set(name, createHash("sha256").update(body).digest("hex")); + } + const verified = await verifyRelease({ + version: "1.2.3", + archives: root, + fetchImpl: async (url) => { + const name = url.slice(url.lastIndexOf("/") + 1); + return response(200, `${JSON.stringify({ vers: "1.2.3", cksum: checksums.get(name) })}\n`); + }, + }); + assert.deepEqual(verified, crateNames); +}); diff --git a/scripts/check-release-control.mjs b/scripts/check-release-control.mjs index 9c519f6..5b39650 100644 --- a/scripts/check-release-control.mjs +++ b/scripts/check-release-control.mjs @@ -72,11 +72,15 @@ export async function checkReleaseControl(root = resolve(import.meta.dirname, ". expect(publisher.on?.workflow_run?.workflows?.includes("Release Yield"), "stable packages must consume the release controller receipt"); expect(publisher.jobs?.npm?.permissions?.contents === "read" && publisher.jobs?.npm?.permissions?.["id-token"] === "write", "npm publisher must use read-only source plus OIDC"); expect(publisher.jobs?.pypi?.permissions?.contents === "read" && publisher.jobs?.pypi?.permissions?.["id-token"] === "write", "PyPI publisher must use read-only source plus OIDC"); + expect(publisher.jobs?.crates?.permissions?.contents === "read" && publisher.jobs?.crates?.permissions?.["id-token"] === "write", "crates.io publisher must use read-only source plus OIDC"); expect(publisher.jobs?.pypi?.environment === "pypi-production", "stable PyPI publishing must use the protected pypi-production environment"); + expect(publisher.jobs?.crates?.environment === "crates-production", "stable crates.io publishing must use the protected crates-production environment"); const pythonWheelStep = publisher.jobs?.build?.steps?.find((step) => step.name === "Build Python wheels"); expect(pythonWheelStep?.if === "needs.resolve.outputs.channel == 'stable'", "PyPI wheels must be built only for stable PEP 440 versions"); expect(raw["npm-publish.yml"].indexOf("Publish platform runtimes") < raw["npm-publish.yml"].indexOf("Publish SDK and CLI"), "runtime packages must publish before the SDK package"); expect(raw["npm-publish.yml"].includes("pypa/gh-action-pypi-publish@"), "PyPI publishing must use the trusted-publishing action"); + expect(raw["npm-publish.yml"].includes("rust-lang/crates-io-auth-action@"), "crates.io publishing must use the trusted-publishing action"); + expect(raw["npm-publish.yml"].indexOf("rust/runtime/*") < raw["npm-publish.yml"].indexOf("rust/yieldskill"), "Rust runtime crates must publish before the SDK crate"); expect(!raw["npm-publish.yml"].includes("skip-existing"), "PyPI retries must verify hashes instead of blindly skipping existing files"); const finalizer = workflows["release-finalize.yml"]; @@ -89,6 +93,9 @@ export async function checkReleaseControl(root = resolve(import.meta.dirname, ". expect(raw["release-finalize.yml"].includes("--draft=false"), "only the receipt finalizer may publish the GitHub release"); expect(raw["release-finalize.yml"].includes("npm-publish.yml"), "finalization must bind the combined publisher receipt"); expect(raw["release-finalize.yml"].includes("pypi-release.mjs verify"), "finalization must verify the PyPI wheel hashes"); + expect(raw["release-finalize.yml"].includes("crates-release.mjs verify"), "finalization must verify the crates.io package hashes"); + const bootstrapSecretUses = Object.values(raw).reduce((count, text) => count + (text.match(/secrets\.CRATES_BOOTSTRAP_TOKEN/g) ?? []).length, 0); + expect(bootstrapSecretUses === 1, "the one-time crates.io bootstrap token must be scoped only to the protected publisher job"); for (const [name, text] of Object.entries(raw)) { expect(!/NPM_TOKEN|NODE_AUTH_TOKEN|PYPI_TOKEN|secrets\.(npm|pypi)|password:/i.test(text), `${name}: long-lived registry credentials are forbidden`); } diff --git a/scripts/readme.test.mjs b/scripts/readme.test.mjs index 21d8b6e..33591cd 100644 --- a/scripts/readme.test.mjs +++ b/scripts/readme.test.mjs @@ -53,6 +53,59 @@ test("Python README example matches the tested environment doctor", async () => assert.equal(readmeProgram, sourceMatch[1].trim()); }); +test("Rust README example matches the tested data migration", async () => { + const [readme, source, fixture] = await Promise.all([ + text("sdk/rust/README.md"), + text("examples/data-migration/src/main.rs"), + text("examples/data-migration/fixtures/responses.json"), + ]); + + const readmeMatch = readme.match( + /\s*```rust\n([\s\S]*?)\n```\s*/, + ); + assert.ok(readmeMatch, "Rust README example markers are missing"); + + const sourceMatch = source.match( + /\/\/ README_EXAMPLE_START\n([\s\S]*?)\n\/\/ README_EXAMPLE_END/, + ); + assert.ok(sourceMatch, "Rust source example markers are missing"); + assert.equal(readmeMatch[1].trim(), sourceMatch[1].trim()); + + const fixtureMatch = readme.match( + /\s*```json\n([\s\S]*?)\n```\s*/, + ); + assert.ok(fixtureMatch, "Rust README fixture markers are missing"); + assert.deepEqual(JSON.parse(fixtureMatch[1]), JSON.parse(fixture)); +}); + +test("Rust README presents a public five-step workflow", async () => { + const readme = await text("sdk/rust/README.md"); + const headings = [ + "### 1. Install Yield", + "### 2. Create the workflow", + "### 3. Test the workflow", + "### 4. Register the skill", + "### 5. Run the skill", + ]; + + let previous = -1; + for (const heading of headings) { + const current = readme.indexOf(heading); + assert.ok(current > previous, `${heading} is missing or out of order`); + 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, /^\/data-migration$/m); + assert.match(readme, /https:\/\/crates\.io\/crates\/yieldskill/); + assert.match(readme, /https:\/\/docs\.rs\/yieldskill/); + assert.doesNotMatch(readme, /get\.operatorstack\.systems\/cargo|npmjs\.com|pypi\.org/); + assert.doesNotMatch(readme, /(?:href|src)="(?!https:\/\/)/); +}); + test("Python README presents a public five-step workflow", async () => { const readme = await text("sdk/python/README.md"); const headings = [ @@ -162,9 +215,10 @@ test("README uses the borderless Yield mark", async () => { }); test("README and quickstart use the public documentation and package registries", async () => { - const [readme, pythonReadme, docsIndex, quickstart, agentSetup] = await Promise.all([ + const [readme, pythonReadme, rustReadme, docsIndex, quickstart, agentSetup] = await Promise.all([ text("README.md"), text("sdk/python/README.md"), + text("sdk/rust/README.md"), text("docs/README.md"), text("docs/quickstart.md"), text("docs/agent-setup.md"), @@ -172,9 +226,12 @@ test("README and quickstart use the public documentation and package registries" assert.match(readme, /href="https:\/\/yield\.operatorstack\.systems\/docs\/">Documentation<\/a>/); assert.match(readme, /href="https:\/\/pypi\.org\/project\/yieldskill\/">PyPI<\/a>/); + assert.match(readme, /href="https:\/\/crates\.io\/crates\/yieldskill">crates\.io<\/a>/); 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.doesNotMatch(rustReadme, /get\.operatorstack\.systems\/cargo/); assert.match(docsIndex, /\[public documentation\]\(https:\/\/yield\.operatorstack\.systems\/docs\/\)/); assert.match(quickstart, /npm install --save-exact @operatorstack\/yield/); assert.doesNotMatch(quickstart, /get\.operatorstack\.systems\/npm|@operatorstack\/yield@0\./); diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml index 312f640..86c0c10 100644 --- a/sdk/rust/Cargo.toml +++ b/sdk/rust/Cargo.toml @@ -5,6 +5,11 @@ edition = "2021" description = "Build portable, resumable skill workflows in Rust." license = "MIT" repository = "https://github.com/operatorstack/yield" +homepage = "https://yield.operatorstack.systems/" +documentation = "https://docs.rs/yieldskill" +readme = "README.md" +keywords = ["agents", "automation", "cli", "workflow"] +categories = ["command-line-utilities", "development-tools"] [dependencies] serde = { version = "1", features = ["derive"] } diff --git a/sdk/rust/README.md b/sdk/rust/README.md new file mode 100644 index 0000000..89c9924 --- /dev/null +++ b/sdk/rust/README.md @@ -0,0 +1,236 @@ +

+ + Yield + +

+ +

Yield for Rust

+ +

Move repeatable coding-agent instructions from words into Rust.

+ +

+ Build typed, resumable workflows that stay beside the code they operate on. +

+ +

+ crates.io version + docs.rs + Build status + MIT license +

+ +

+ Website · + Documentation · + crates.io · + docs.rs · + GitHub +

+ +The crate and library names are both `yieldskill`. The installed command is +`yskill`. + +## Build a Rust skill in five steps + +### 1. Install Yield + +Yield supports Rust on macOS, Linux, and Windows. Install the public crate: + +```bash +cargo install yieldskill --locked +yskill --version +``` + +The crate contains the matching `yskill` runtime for your platform. You do not +need Go, Node.js, or a separate CLI download. + +### 2. Create the workflow + +Create a Rust workflow inside your repository: + +```bash +yskill init skills/data-migration \ + --language rust \ + --description "Dry-run, approve, apply, and verify a database migration." +``` + +Replace `skills/data-migration/src/main.rs` with this tested workflow: + + +```rust +use serde_json::json; +use yieldskill::{define_skill, Context, SkillResult}; + +fn program(ctx: &mut Context) -> SkillResult { + let dry_run = ctx.run_command("dry-run", "echo 'plan: add index users_email_idx'", 300); + ctx.require( + dry_run.exit_code == 0, + "the dry run succeeds", + Some(&json!({ "exit_code": dry_run.exit_code })), + ); + + let review = ctx.agent_task( + "summarize-plan", + "Summarize the migration plan for the operator: what changes, what is irreversible, what the rollback is.", + Some(json!({ "plan": dry_run.stdout })), + Some(json!({ + "type": "object", + "required": ["summary"], + "properties": { "summary": { "type": "string", "minLength": 1 } } + })), + ); + + let approval = ctx.ask_user( + "approve-apply", + "Apply the migration to the live database?", + &[("apply", "Apply now"), ("abort", "Abort")], + ); + if approval != "apply" { + return Err(ctx.refused("the operator declined to apply the migration")); + } + + let apply = ctx.run_command("apply", "echo apply-ok", 600); + ctx.require( + apply.exit_code == 0, + "the migration applies cleanly", + Some(&json!({ "exit_code": apply.exit_code })), + ); + + let verify = ctx.run_command("verify", "echo verify-ok", 300); + ctx.require( + verify.exit_code == 0, + "post-apply verification passes", + Some(&json!({ "exit_code": verify.exit_code })), + ); + + Ok(json!({ + "applied": true, + "summary": review["summary"], + })) +} + +fn main() { + define_skill(program); +} +``` + + +The generated `Cargo.toml` pins the public `yieldskill` crate to the installed +CLI version. The generated `skill.json` runs the named Rust binary. + +### 3. Test the workflow + +Use deterministic fixture responses during tests. Save this as +`skills/data-migration/fixtures/responses.json`: + + +```json +{ + "summarize-plan": { + "summary": "Adds users_email_idx concurrently; no data rewrite; rollback is DROP INDEX. The only irreversible step is index creation time." + }, + "approve-apply": { "value": "apply" } +} +``` + + +Then test the workflow: + +```bash +yskill doctor skills/data-migration --test +``` + +Yield runs commands for real and supplies agent and user responses from the +fixture. A successful test reaches `completed` without leaving a run journal. + +### 4. Register the skill + +Registration lets installed coding agents discover the workflow: + +```bash +yskill register skills/data-migration +``` + +Select the verified agents explicitly when you do not want automatic +detection: + +```bash +yskill register skills/data-migration \ + --agent cursor,codex,claude-code +``` + +The generated adapters point back to `skills/data-migration`. They do not copy +the workflow or install its dependencies again. + +### 5. Run the skill + +Start a new coding-agent session so it discovers the registered skill. Where +slash skills are supported, run: + +```text +/data-migration +``` + +Otherwise, ask the agent in plain language: + +```text +Use the data-migration skill to apply this migration safely. +``` + +The agent follows the adapter, starts the canonical Rust workflow, and asks for +each required agent or user response. + +## How Yield runs and resumes + +1. Your Rust function emits one typed operation. +2. Yield records the request and exits. It does not run a daemon. +3. The coding agent, user, or CLI supplies the result. +4. Yield replays the function from its journal until it reaches the next + operation. + +Replay must produce the same operation sequence. Yield reports divergence +instead of giving a recorded response to a different operation. + +| Rust primitive | Purpose | +|---|---| +| `ctx.run_command()` | Execute a command and record its exit code and output. | +| `ctx.agent_task()` | Ask the coding agent for schema-valid JSON. | +| `ctx.ask_user()` | Request an explicit human decision. | +| `ctx.require()` | Bind a required claim to recorded evidence. | +| `ctx.blocked()` / `ctx.refused()` | Stop honestly when work cannot or must not continue. | + +See the [primitive guides](https://yield.operatorstack.systems/docs/primitives/) +and [CLI reference](https://github.com/operatorstack/yield/blob/main/docs/reference/cli.md) +for the complete contract. + +## Guarantees and limits + +Yield provides deterministic control flow, typed requests and responses, +persistent run state, replay with divergence detection, stale and duplicate +response rejection, and evidence-bound completion. + +Schema validity is not truth. Yield cannot prove that a coding agent performed +only the requested work. `run_command` is different: the Yield CLI executes the +command, so its recorded exit code and output are observed facts. + +Programs must remain deterministic between operations. Do not read clocks, +random values, environment variables, or changing files to choose the next +operation. Cross those boundaries through a Yield operation instead. + +Yield is not a daemon, hosted runtime, workflow DSL, marketplace, coding-agent +loop, multi-agent orchestrator, or security sandbox. + +## Coding agents and source + +Cursor, Codex, and Claude Code are verified integrations. Yield also provides +registry-backed project paths for other coding agents; those paths are not +presented as end-to-end verified. + +- [Read the documentation](https://yield.operatorstack.systems/docs/) +- [Explore tested examples](https://github.com/operatorstack/yield/tree/main/examples) +- [View the Rust source](https://github.com/operatorstack/yield/tree/main/sdk/rust) +- [Read the API documentation](https://docs.rs/yieldskill) +- [Report an issue](https://github.com/operatorstack/yield/issues) + +Yield is available under the +[MIT license](https://github.com/operatorstack/yield/blob/main/LICENSE). From 581b96b528628010ca76a16a2dbf65cc63825a12 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Fri, 7 Aug 2026 22:46:45 +0100 Subject: [PATCH 2/2] Refresh evaluation evidence --- evals/results/latest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evals/results/latest.json b/evals/results/latest.json index d5785ae..6fe888b 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-07T21:08:41.456Z", - "source_digest": "e702eb9bc1362b9a4d77fd41c9860bad97405c7aab3b40aba3a6ba3046c43933", + "generated_at": "2026-08-07T21:46:28.142Z", + "source_digest": "e70b0de3a9db5924311609a33e3ce91b539da02a6822acbf57d00471d8f6b63a", "status": "passed", "workflow_conformance": { "passed": 40,