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 @@
+
@@ -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 for Rust
+
+Move repeatable coding-agent instructions from words into Rust.
+
+
+ Build typed, resumable workflows that stay beside the code they operate on.
+
+
+
+
+
+
+
+
+
+
+ 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,