From 0db1c1485de98a4b44525dc2324912a5176dd365 Mon Sep 17 00:00:00 2001
From: bigboateng
Date: Fri, 7 Aug 2026 21:15:27 +0100
Subject: [PATCH 1/2] Publish Yield Python packages on PyPI
---
.changeset/publish-python-on-pypi.md | 5 +
.../{npm-publish.yml => package-publish.yml} | 115 ++++++++++++++---
.github/workflows/release-finalize.yml | 17 ++-
README.md | 2 +
packaging/assemble.test.mjs | 12 +-
packaging/pypi-release.mjs | 119 ++++++++++++++++++
packaging/pypi-release.test.mjs | 57 +++++++++
scripts/audit-repository-controls.mjs | 16 ++-
scripts/audit-repository-controls.test.mjs | 15 +++
scripts/check-release-control.mjs | 27 ++--
scripts/readme.test.mjs | 9 +-
sdk/python/LICENSE | 21 ++++
sdk/python/README.md | 9 +-
sdk/python/pyproject.toml | 18 ++-
sdk/python/yieldskill/_runtime/__init__.py | 1 +
15 files changed, 400 insertions(+), 43 deletions(-)
create mode 100644 .changeset/publish-python-on-pypi.md
rename .github/workflows/{npm-publish.yml => package-publish.yml} (61%)
create mode 100644 packaging/pypi-release.mjs
create mode 100644 packaging/pypi-release.test.mjs
create mode 100644 sdk/python/LICENSE
create mode 100644 sdk/python/yieldskill/_runtime/__init__.py
diff --git a/.changeset/publish-python-on-pypi.md b/.changeset/publish-python-on-pypi.md
new file mode 100644
index 0000000..264537f
--- /dev/null
+++ b/.changeset/publish-python-on-pypi.md
@@ -0,0 +1,5 @@
+---
+"@operatorstack/yield": patch
+---
+
+Publish the Python SDK and its six version-matched runtimes on PyPI through the supervised stable release.
diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/package-publish.yml
similarity index 61%
rename from .github/workflows/npm-publish.yml
rename to .github/workflows/package-publish.yml
index 93ef4b9..f319fda 100644
--- a/.github/workflows/npm-publish.yml
+++ b/.github/workflows/package-publish.yml
@@ -1,4 +1,4 @@
-name: Publish Yield to npm
+name: Publish Yield packages
on:
push:
@@ -15,10 +15,9 @@ on:
permissions:
contents: read
- id-token: write
concurrency:
- group: npm-${{ github.event_name == 'push' && 'canary' || 'stable' }}
+ group: packages-${{ github.event_name == 'push' && 'canary' || 'stable' }}
cancel-in-progress: false
jobs:
@@ -77,11 +76,10 @@ jobs:
echo "dist_tag=$dist_tag" >> "$GITHUB_OUTPUT"
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
- publish:
+ build:
needs: resolve
if: needs.resolve.outputs.publish == 'true'
runs-on: ubuntu-latest
- environment: ${{ needs.resolve.outputs.channel == 'stable' && 'npm-production' || 'npm-canary' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -95,14 +93,15 @@ jobs:
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: "24"
- registry-url: https://registry.npmjs.org
package-manager-cache: false
- - name: Pin npm trusted-publishing client
- run: npm install --global npm@12.0.2
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
+ with:
+ python-version: "3.12"
- name: Verify source
run: |
go test ./...
node --test packaging/*.test.mjs sdk/typescript/bin/*.test.mjs
+ python -m unittest discover -s sdk/python -p 'test_*.py'
- name: Build immutable runtimes
env:
VERSION: ${{ needs.resolve.outputs.version }}
@@ -120,6 +119,18 @@ jobs:
-o "dist/bin/yskill-${goos}-${goarch}${suffix}" ./cmd/yskill
done
node packaging/assemble.mjs --version "$VERSION" --binaries dist/bin --output dist/packages
+ - name: Build Python wheels
+ env:
+ VERSION: ${{ needs.resolve.outputs.version }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ python -m pip install --disable-pip-version-check build==1.3.0 setuptools==80.9.0 wheel==0.45.1
+ mkdir -p dist/pypi
+ for directory in dist/packages/python/*; do
+ 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: Inspect npm tarballs
shell: bash
run: |
@@ -127,13 +138,37 @@ jobs:
for directory in dist/packages/npm/*; do
(cd "$directory" && npm pack --dry-run)
done
+ - name: Assemble immutable release unit
+ run: |
+ mkdir -p dist/release-unit
+ cp dist/packages/SHA256SUMS.json dist/release-unit/
+ cp -R dist/packages/npm dist/release-unit/npm
+ cp -R dist/pypi dist/release-unit/pypi
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
- name: npm-${{ needs.resolve.outputs.version }}-release-unit
- path: |
- dist/packages/SHA256SUMS.json
- dist/packages/npm/
+ name: packages-${{ needs.resolve.outputs.version }}-${{ needs.resolve.outputs.source_sha }}
+ path: dist/release-unit/
if-no-files-found: error
+
+ npm:
+ needs: [resolve, build]
+ runs-on: ubuntu-latest
+ environment: ${{ needs.resolve.outputs.channel == 'stable' && 'npm-production' || 'npm-canary' }}
+ permissions:
+ contents: read
+ id-token: write
+ steps:
+ - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
+ with:
+ name: packages-${{ needs.resolve.outputs.version }}-${{ needs.resolve.outputs.source_sha }}
+ path: dist/release-unit
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: "24"
+ registry-url: https://registry.npmjs.org
+ package-manager-cache: false
+ - name: Pin npm trusted-publishing client
+ run: npm install --global npm@12.0.2
- name: Publish platform runtimes
env:
DIST_TAG: ${{ needs.resolve.outputs.dist_tag }}
@@ -141,7 +176,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
- for directory in dist/packages/npm/darwin-* dist/packages/npm/linux-* dist/packages/npm/windows-*; do
+ 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")"
if npm view "${package}@${VERSION}" version >/dev/null 2>&1; then
echo "${package}@${VERSION} already exists"
@@ -159,15 +194,15 @@ jobs:
if npm view "@operatorstack/yield@${VERSION}" version >/dev/null 2>&1; then
echo "@operatorstack/yield@${VERSION} already exists"
else
- (cd dist/packages/npm/yield && npm publish --tag "$DIST_TAG")
+ (cd dist/release-unit/npm/yield && npm publish --tag "$DIST_TAG")
fi
- - name: Verify complete public release unit
+ - name: Verify complete npm release unit
env:
VERSION: ${{ needs.resolve.outputs.version }}
shell: bash
run: |
set -euo pipefail
- for directory in dist/packages/npm/*; do
+ for directory in dist/release-unit/npm/*; do
package="$(node -p "require('./${directory}/package.json').name")"
for attempt in {1..12}; do
if [[ "$(npm view "${package}@${VERSION}" version 2>/dev/null || true)" == "$VERSION" ]]; then break; fi
@@ -175,3 +210,51 @@ jobs:
sleep 10
done
done
+
+ pypi:
+ needs: [resolve, build]
+ if: needs.resolve.outputs.channel == 'stable'
+ runs-on: ubuntu-latest
+ environment: pypi-production
+ permissions:
+ contents: read
+ id-token: write
+ 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
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: "24"
+ package-manager-cache: false
+ - id: prepare
+ name: Refuse PyPI drift and select missing wheels
+ env:
+ VERSION: ${{ needs.resolve.outputs.version }}
+ run: >-
+ node packaging/pypi-release.mjs prepare
+ --version "$VERSION"
+ --dist dist/release-unit/pypi
+ --upload dist/pypi-upload
+ --output "$GITHUB_OUTPUT"
+ - name: Publish Python wheels with trusted publishing
+ if: steps.prepare.outputs.publish == 'true'
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
+ with:
+ packages-dir: dist/pypi-upload
+ attestations: true
+ verbose: true
+ - name: Verify complete PyPI release unit
+ env:
+ VERSION: ${{ needs.resolve.outputs.version }}
+ run: >-
+ node packaging/pypi-release.mjs verify
+ --version "$VERSION"
+ --dist dist/release-unit/pypi
+ --attempts 12
+ --delay-ms 10000
diff --git a/.github/workflows/release-finalize.yml b/.github/workflows/release-finalize.yml
index 2cea106..8ed8b76 100644
--- a/.github/workflows/release-finalize.yml
+++ b/.github/workflows/release-finalize.yml
@@ -2,7 +2,7 @@ name: Finalize Yield release
on:
workflow_run:
- workflows: ["Publish Yield to npm"]
+ workflows: ["Publish Yield packages"]
types: [completed]
workflow_dispatch:
inputs:
@@ -85,9 +85,13 @@ jobs:
run: |
set -euo pipefail
version="${TAG#v}"
- conclusion="$(gh api "/repos/${GITHUB_REPOSITORY}/actions/workflows/npm-publish.yml/runs?head_sha=${SOURCE_SHA}&status=completed&per_page=30" \
- --jq '[.workflow_runs[] | select(.event == "workflow_run" or .event == "workflow_dispatch")][0].conclusion // "missing"')"
- test "$conclusion" = success
+ run="$(gh api "/repos/${GITHUB_REPOSITORY}/actions/workflows/package-publish.yml/runs?head_sha=${SOURCE_SHA}&status=completed&per_page=30" \
+ --jq '[.workflow_runs[] | select(.event == "workflow_run" or .event == "workflow_dispatch")][0] // {}')"
+ test "$(jq -r '.conclusion // "missing"' <<< "$run")" = success
+ run_id="$(jq -r '.id // empty' <<< "$run")"
+ test -n "$run_id"
+ gh run download "$run_id" --repo "$GITHUB_REPOSITORY" \
+ --name "packages-${version}-${SOURCE_SHA}" --dir "$RUNNER_TEMP/release-unit"
for package in \
@operatorstack/yield \
@operatorstack/yield-darwin-amd64 @operatorstack/yield-darwin-arm64 \
@@ -95,5 +99,10 @@ jobs:
@operatorstack/yield-windows-amd64 @operatorstack/yield-windows-arm64; do
test "$(npm view "${package}@${version}" version)" = "$version"
done
+ node packaging/pypi-release.mjs verify \
+ --version "$version" \
+ --dist "$RUNNER_TEMP/release-unit/pypi" \
+ --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 46c1360..1cf1b95 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@
+
@@ -22,6 +23,7 @@
Website ·
Documentation ·
npm ·
+ PyPI ·
GitHub
diff --git a/packaging/assemble.test.mjs b/packaging/assemble.test.mjs
index a0658d3..75d4dfb 100644
--- a/packaging/assemble.test.mjs
+++ b/packaging/assemble.test.mjs
@@ -16,7 +16,7 @@ test("accepts stable and exact Yield canary versions", () => {
assert.equal(isPackageVersion("v1.2.3"), false);
});
-test("assembles one public npm package and six matching runtimes", async (t) => {
+test("assembles one public npm package and six matching npm and Python runtimes", async (t) => {
const root = await mkdtemp(join(tmpdir(), "yield-assemble-"));
t.after(() => rm(root, { recursive: true, force: true }));
@@ -62,5 +62,15 @@ test("assembles one public npm package and six matching runtimes", async (t) =>
assert.deepEqual(runtime.cpu, [target.nodeCpu]);
assert.equal(runtime.publishConfig.provenance, true);
assert.match(await readFile(join(output, `npm/${target.id}/LICENSE`), "utf8"), /MIT License/);
+
+ const pythonRoot = join(output, `python/${target.id}`);
+ assert.match(await readFile(join(pythonRoot, "pyproject.toml"), "utf8"), /version = "1\.2\.3"/);
+ assert.match(await readFile(join(pythonRoot, "setup.py"), "utf8"), new RegExp(target.pythonTag));
+ assert.match(await readFile(join(pythonRoot, "LICENSE"), "utf8"), /MIT License/);
+ const pythonRuntime = target.goos === "windows" ? "yskill.exe" : "yskill";
+ assert.equal(
+ await readFile(join(pythonRoot, "yieldskill/_runtime", pythonRuntime), "utf8"),
+ `runtime:${target.id}`,
+ );
}
});
diff --git a/packaging/pypi-release.mjs b/packaging/pypi-release.mjs
new file mode 100644
index 0000000..bc546e1
--- /dev/null
+++ b/packaging/pypi-release.mjs
@@ -0,0 +1,119 @@
+#!/usr/bin/env node
+import { createHash } from "node:crypto";
+import { copyFile, mkdir, readdir, readFile, rm } from "node:fs/promises";
+import { join, resolve } from "node:path";
+import process from "node:process";
+import { pathToFileURL } from "node:url";
+import { targets } from "./targets.mjs";
+
+const stableVersion = /^\d+\.\d+\.\d+$/;
+
+function expect(condition, message) {
+ if (!condition) throw new Error(message);
+}
+
+function parseArgs(argv) {
+ const [command, ...rest] = argv;
+ const values = {};
+ for (let index = 0; index < rest.length; index += 2) {
+ const key = rest[index];
+ if (!key?.startsWith("--") || rest[index + 1] === undefined) throw new Error(`invalid argument ${key ?? ""}`);
+ values[key.slice(2)] = rest[index + 1];
+ }
+ expect(["inspect", "prepare", "verify"].includes(command), "command must be inspect, prepare, or verify");
+ expect(stableVersion.test(values.version ?? ""), "--version must be stable semver");
+ expect(values.dist, "--dist is required");
+ if (command === "prepare") expect(values.upload, "--upload is required for prepare");
+ return { command, ...values };
+}
+
+export function expectedWheelNames(version) {
+ expect(stableVersion.test(version), `invalid stable version ${version}`);
+ return targets.map((target) => `yieldskill-${version}-py3-none-${target.pythonTag}.whl`).sort();
+}
+
+async function sha256(path) {
+ return createHash("sha256").update(await readFile(path)).digest("hex");
+}
+
+export async function inspectLocalRelease(directory, version) {
+ const expected = expectedWheelNames(version);
+ const actual = (await readdir(directory)).filter((name) => name.endsWith(".whl")).sort();
+ expect(JSON.stringify(actual) === JSON.stringify(expected), `wheel set mismatch: expected ${expected.join(", ")}; got ${actual.join(", ")}`);
+ return Promise.all(actual.map(async (filename) => ({ filename, sha256: await sha256(join(directory, filename)) })));
+}
+
+export function compareRelease(local, remote) {
+ const localByName = new Map(local.map((file) => [file.filename, file.sha256]));
+ expect(localByName.size === local.length, "local wheel filenames must be unique");
+ const remoteByName = new Map();
+ for (const file of remote) {
+ expect(!remoteByName.has(file.filename), `duplicate remote file ${file.filename}`);
+ expect(localByName.has(file.filename), `unexpected remote file ${file.filename}`);
+ remoteByName.set(file.filename, file.sha256);
+ expect(localByName.get(file.filename) === file.sha256, `remote hash mismatch for ${file.filename}`);
+ }
+ return local.filter((file) => !remoteByName.has(file.filename));
+}
+
+export async function fetchPyPIRelease(version, fetchImpl = fetch) {
+ const response = await fetchImpl(`https://pypi.org/pypi/yieldskill/${version}/json`, {
+ headers: { Accept: "application/json" },
+ cache: "no-store",
+ });
+ if (response.status === 404) return [];
+ expect(response.ok, `PyPI returned HTTP ${response.status}`);
+ const payload = await response.json();
+ return (payload.urls ?? []).map((file) => ({ filename: file.filename, sha256: file.digests?.sha256 ?? "" }));
+}
+
+export async function prepareUpload({ dist, upload, version, remote }) {
+ const local = await inspectLocalRelease(dist, version);
+ const missing = compareRelease(local, remote);
+ await rm(upload, { recursive: true, force: true });
+ await mkdir(upload, { recursive: true });
+ for (const file of missing) await copyFile(join(dist, file.filename), join(upload, file.filename));
+ return { local, missing };
+}
+
+async function appendOutput(path, values) {
+ if (!path) return;
+ const { appendFile } = await import("node:fs/promises");
+ await appendFile(path, Object.entries(values).map(([key, value]) => `${key}=${value}\n`).join(""));
+}
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2));
+ const dist = resolve(options.dist);
+ const version = options.version;
+ if (options.command === "inspect") {
+ const local = await inspectLocalRelease(dist, version);
+ process.stdout.write(`${JSON.stringify({ version, files: local }, null, 2)}\n`);
+ return;
+ }
+ if (options.command === "prepare") {
+ const remote = await fetchPyPIRelease(version);
+ const result = await prepareUpload({ dist, upload: resolve(options.upload), version, remote });
+ await appendOutput(options.output, { publish: result.missing.length > 0, missing_count: result.missing.length });
+ process.stdout.write(`${JSON.stringify({ version, missing: result.missing.map(({ filename }) => filename) }, null, 2)}\n`);
+ return;
+ }
+
+ const attempts = Number(options.attempts ?? "1");
+ const delayMs = Number(options["delay-ms"] ?? "0");
+ expect(Number.isInteger(attempts) && attempts > 0, "--attempts must be a positive integer");
+ const local = await inspectLocalRelease(dist, version);
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
+ const missing = compareRelease(local, await fetchPyPIRelease(version));
+ if (missing.length === 0) {
+ process.stdout.write(`${JSON.stringify({ version, verified: true, files: local.length })}\n`);
+ return;
+ }
+ if (attempt === attempts) throw new Error(`PyPI release is incomplete: ${missing.map(({ filename }) => filename).join(", ")}`);
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs));
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
+ main().catch((error) => { console.error(`pypi-release: ${error.message}`); process.exit(1); });
+}
diff --git a/packaging/pypi-release.test.mjs b/packaging/pypi-release.test.mjs
new file mode 100644
index 0000000..865b930
--- /dev/null
+++ b/packaging/pypi-release.test.mjs
@@ -0,0 +1,57 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { compareRelease, expectedWheelNames, fetchPyPIRelease, inspectLocalRelease, prepareUpload } from "./pypi-release.mjs";
+
+async function fixture(t, version = "1.2.3") {
+ const root = await mkdtemp(join(tmpdir(), "yield-pypi-"));
+ t.after(() => rm(root, { recursive: true, force: true }));
+ const dist = join(root, "dist");
+ const upload = join(root, "upload");
+ const { mkdir } = await import("node:fs/promises");
+ await mkdir(dist);
+ for (const name of expectedWheelNames(version)) await writeFile(join(dist, name), `wheel:${name}`);
+ return { dist, upload, version };
+}
+
+test("requires the complete six-wheel release unit", async (t) => {
+ const { dist, version } = await fixture(t);
+ assert.equal((await inspectLocalRelease(dist, version)).length, 6);
+ await rm(join(dist, expectedWheelNames(version)[0]));
+ await assert.rejects(inspectLocalRelease(dist, version), /wheel set mismatch/);
+});
+
+test("accepts matching remote files and returns only missing wheels", async (t) => {
+ const { dist, version } = await fixture(t);
+ const local = await inspectLocalRelease(dist, version);
+ assert.deepEqual(compareRelease(local, local.slice(0, 2)), local.slice(2));
+ assert.throws(() => compareRelease(local, [{ filename: local[0].filename, sha256: "wrong" }]), /hash mismatch/);
+ assert.throws(() => compareRelease(local, [{ filename: "yieldskill-1.2.3.tar.gz", sha256: "x" }]), /unexpected remote file/);
+});
+
+test("prepares an idempotent upload directory", async (t) => {
+ const { dist, upload, version } = await fixture(t);
+ const local = await inspectLocalRelease(dist, version);
+ const partial = await prepareUpload({ dist, upload, version, remote: local.slice(0, 4) });
+ assert.equal(partial.missing.length, 2);
+ assert.deepEqual((await readdir(upload)).sort(), partial.missing.map(({ filename }) => filename).sort());
+ const complete = await prepareUpload({ dist, upload, version, remote: local });
+ assert.equal(complete.missing.length, 0);
+ assert.deepEqual(await readdir(upload), []);
+});
+
+test("treats a missing PyPI project as an empty release", async () => {
+ const files = await fetchPyPIRelease("1.2.3", async () => ({ status: 404, ok: false }));
+ assert.deepEqual(files, []);
+});
+
+test("reads PyPI SHA-256 receipts", async () => {
+ const files = await fetchPyPIRelease("1.2.3", async () => ({
+ status: 200,
+ ok: true,
+ json: async () => ({ urls: [{ filename: "a.whl", digests: { sha256: "abc" } }] }),
+ }));
+ assert.deepEqual(files, [{ filename: "a.whl", sha256: "abc" }]);
+});
diff --git a/scripts/audit-repository-controls.mjs b/scripts/audit-repository-controls.mjs
index 1d78422..5a9430b 100644
--- a/scripts/audit-repository-controls.mjs
+++ b/scripts/audit-repository-controls.mjs
@@ -14,7 +14,7 @@ function expect(condition, message) {
if (!condition) throw new Error(message);
}
-export function auditRepositoryControls({ workflow, actions, protection, rulesets }) {
+export function auditRepositoryControls({ workflow, actions, protection, rulesets, pypiEnvironment }) {
expect(workflow.default_workflow_permissions === "read", "default workflow permissions must be read-only");
expect(workflow.can_approve_pull_request_reviews === false, "workflows must not approve pull requests");
expect(actions.enabled === true && actions.sha_pinning_required === true, "Actions must require immutable SHA references");
@@ -26,7 +26,12 @@ export function auditRepositoryControls({ workflow, actions, protection, ruleset
for (const check of requiredChecks) expect(contexts.has(check), `main is missing required check: ${check}`);
const tagRule = rulesets.find((ruleset) => ruleset.name === "Immutable Yield release tags");
expect(tagRule?.target === "tag" && tagRule.enforcement === "active", "immutable release-tag ruleset must be active");
- return { requiredChecks: requiredChecks.length, immutableTagRuleset: tagRule.id };
+ expect(pypiEnvironment?.name === "pypi-production", "pypi-production environment must exist");
+ expect(pypiEnvironment.deployment_branch_policy?.protected_branches === true, "PyPI production must accept protected branches only");
+ const reviewerRules = pypiEnvironment.protection_rules?.filter(({ type }) => type === "required_reviewers") ?? [];
+ expect(reviewerRules.some(({ reviewers }) => reviewers?.some(({ reviewer }) => reviewer?.login === "bigboateng")), "PyPI production must require bigboateng review");
+ expect(reviewerRules.every(({ prevent_self_review }) => prevent_self_review === false), "PyPI production must permit the authorized operator to approve the first release");
+ return { requiredChecks: requiredChecks.length, immutableTagRuleset: tagRule.id, pypiEnvironment: pypiEnvironment.name };
}
async function github(path) {
@@ -44,14 +49,15 @@ async function github(path) {
async function main() {
expect(process.env.GH_TOKEN, "GH_TOKEN is required");
- const [workflow, actions, protection, rulesets] = await Promise.all([
+ const [workflow, actions, protection, rulesets, pypiEnvironment] = await Promise.all([
github("actions/permissions/workflow"),
github("actions/permissions"),
github("branches/main/protection"),
github("rulesets"),
+ github("environments/pypi-production"),
]);
- const result = auditRepositoryControls({ workflow, actions, protection, rulesets });
- console.log(`repository-controls: ${result.requiredChecks} required checks and immutable tag ruleset ${result.immutableTagRuleset} verified`);
+ const result = auditRepositoryControls({ workflow, actions, protection, rulesets, pypiEnvironment });
+ console.log(`repository-controls: ${result.requiredChecks} required checks, immutable tag ruleset ${result.immutableTagRuleset}, and ${result.pypiEnvironment} verified`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
diff --git a/scripts/audit-repository-controls.test.mjs b/scripts/audit-repository-controls.test.mjs
index f3abc0f..fd496ab 100644
--- a/scripts/audit-repository-controls.test.mjs
+++ b/scripts/audit-repository-controls.test.mjs
@@ -18,6 +18,15 @@ function controls(overrides = {}) {
allow_deletions: { enabled: false },
},
rulesets: [{ id: 1, name: "Immutable Yield release tags", target: "tag", enforcement: "active" }],
+ pypiEnvironment: {
+ name: "pypi-production",
+ deployment_branch_policy: { protected_branches: true, custom_branch_policies: false },
+ protection_rules: [{
+ type: "required_reviewers",
+ prevent_self_review: false,
+ reviewers: [{ reviewer: { login: "bigboateng" }, type: "User" }],
+ }],
+ },
...overrides,
};
}
@@ -30,3 +39,9 @@ test("refuses a bypassable administrator or mutable action reference policy", ()
assert.throws(() => auditRepositoryControls(controls({ protection: { ...controls().protection, enforce_admins: { enabled: false } } })), /administrators/);
assert.throws(() => auditRepositoryControls(controls({ actions: { enabled: true, sha_pinning_required: false } })), /immutable SHA/);
});
+
+test("refuses an unreviewed PyPI production environment", () => {
+ assert.throws(() => auditRepositoryControls(controls({
+ pypiEnvironment: { name: "pypi-production", deployment_branch_policy: { protected_branches: true }, protection_rules: [] },
+ })), /require bigboateng review/);
+});
diff --git a/scripts/check-release-control.mjs b/scripts/check-release-control.mjs
index 166e5cd..87e736f 100644
--- a/scripts/check-release-control.mjs
+++ b/scripts/check-release-control.mjs
@@ -65,20 +65,31 @@ export async function checkReleaseControl(root = resolve(import.meta.dirname, ".
expect(raw["release.yml"].includes("--draft"), "release controller must create a draft release");
expect(!raw["release.yml"].includes("--draft=false"), "release controller must not finalize its own release");
- const npm = workflows["npm-publish.yml"];
- expect(npm, "npm-publish.yml is required");
- expect(npm.permissions?.contents === "read" && npm.permissions?.["id-token"] === "write", "npm publisher must use read-only source plus OIDC");
- expect(npm.on?.push?.branches?.includes("main"), "npm canary must follow public main");
- expect(npm.on?.workflow_run?.workflows?.includes("Release Yield"), "stable npm must consume the release controller receipt");
- 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");
+ const publisher = workflows["package-publish.yml"];
+ expect(publisher, "package-publish.yml is required");
+ expect(!names.includes("npm-publish.yml"), "the obsolete single-registry publisher must be removed");
+ expect(publisher.permissions?.contents === "read", "package publisher must default to read-only source access");
+ expect(publisher.on?.push?.branches?.includes("main"), "npm canary must follow public main");
+ 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?.pypi?.environment === "pypi-production", "stable PyPI publishing must use the protected pypi-production environment");
+ expect(raw["package-publish.yml"].indexOf("Publish platform runtimes") < raw["package-publish.yml"].indexOf("Publish SDK and CLI"), "runtime packages must publish before the SDK package");
+ expect(raw["package-publish.yml"].includes("pypa/gh-action-pypi-publish@"), "PyPI publishing must use the trusted-publishing action");
+ expect(!raw["package-publish.yml"].includes("skip-existing"), "PyPI retries must verify hashes instead of blindly skipping existing files");
const finalizer = workflows["release-finalize.yml"];
expect(finalizer?.permissions?.actions === "read" && finalizer.permissions?.contents === "read", "finalizer preflight must be read-only");
- expect(finalizer.jobs?.finalize?.permissions?.contents === "write", "receipt-complete finalization alone needs contents:write");
+ expect(
+ finalizer.jobs?.finalize?.permissions?.actions === "read" && finalizer.jobs?.finalize?.permissions?.contents === "write",
+ "receipt-complete finalization needs artifact read access and contents:write",
+ );
expect(finalizer.jobs?.finalize?.needs === "resolve", "finalization must follow read-only tag resolution");
expect(raw["release-finalize.yml"].includes("--draft=false"), "only the receipt finalizer may publish the GitHub release");
+ expect(raw["release-finalize.yml"].includes("package-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");
for (const [name, text] of Object.entries(raw)) {
- expect(!/NPM_TOKEN|NODE_AUTH_TOKEN|secrets\.npm/i.test(text), `${name}: long-lived npm credentials are forbidden`);
+ expect(!/NPM_TOKEN|NODE_AUTH_TOKEN|PYPI_TOKEN|secrets\.(npm|pypi)|password:/i.test(text), `${name}: long-lived registry credentials are forbidden`);
}
return { workflows: names.length, externalActionsPinned: names.flatMap((name) => usesIn(workflows[name])).filter((ref) => !ref.startsWith("./")).length };
diff --git a/scripts/readme.test.mjs b/scripts/readme.test.mjs
index 78bb6f1..3b662f5 100644
--- a/scripts/readme.test.mjs
+++ b/scripts/readme.test.mjs
@@ -111,15 +111,20 @@ test("README uses the borderless Yield mark", async () => {
assert.match(mark, / {
- const [readme, docsIndex, quickstart, agentSetup] = await Promise.all([
+test("README and quickstart use the public documentation and package registries", async () => {
+ const [readme, pythonReadme, docsIndex, quickstart, agentSetup] = await Promise.all([
text("README.md"),
+ text("sdk/python/README.md"),
text("docs/README.md"),
text("docs/quickstart.md"),
text("docs/agent-setup.md"),
]);
assert.match(readme, /href="https:\/\/yield\.operatorstack\.systems\/docs\/">Documentation<\/a>/);
+ assert.match(readme, /href="https:\/\/pypi\.org\/project\/yieldskill\/">PyPI<\/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(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/python/LICENSE b/sdk/python/LICENSE
new file mode 100644
index 0000000..0bfd7e5
--- /dev/null
+++ b/sdk/python/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Operator Stack
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/sdk/python/README.md b/sdk/python/README.md
index bef3100..29014ad 100644
--- a/sdk/python/README.md
+++ b/sdk/python/README.md
@@ -10,18 +10,19 @@ Create a virtual environment before installation:
# macOS and Linux
python3 -m venv .venv
source .venv/bin/activate
-python -m pip install yieldskill==0.1.29 \
- --index-url https://get.operatorstack.systems/pip/simple/
+python -m pip install yieldskill
```
```powershell
# Windows PowerShell
py -m venv .venv
.\.venv\Scripts\Activate.ps1
-python -m pip install yieldskill==0.1.29 `
- --index-url https://get.operatorstack.systems/pip/simple/
+python -m pip install yieldskill
```
+[Public PyPI releases](https://pypi.org/project/yieldskill/) use trusted
+publishing. Every wheel contains the matching Yield runtime for its platform.
+
The launcher preserves the selected Python environment for `RunCommand`, even
when an adapter starts that interpreter without activating the environment.
diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml
index f153459..deedb1a 100644
--- a/sdk/python/pyproject.toml
+++ b/sdk/python/pyproject.toml
@@ -3,21 +3,33 @@ name = "yieldskill"
version = "0.1.0"
description = "Build portable, resumable skill workflows in Python."
readme = "README.md"
-license = { text = "MIT" }
+license = "MIT"
+license-files = ["LICENSE"]
requires-python = ">=3.10"
+authors = [{ name = "OperatorStack" }]
+maintainers = [{ name = "Boateng Opoku-Yeboah" }]
+keywords = ["coding agents", "skills", "workflows"]
+classifiers = [
+ "Development Status :: 3 - Alpha",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3 :: Only",
+]
[project.urls]
+Homepage = "https://yield.operatorstack.systems/"
+Documentation = "https://yield.operatorstack.systems/docs/"
Repository = "https://github.com/operatorstack/yield"
+Issues = "https://github.com/operatorstack/yield/issues"
[project.scripts]
yskill = "yieldskill._cli:main"
[build-system]
-requires = ["setuptools>=68", "wheel>=0.42"]
+requires = ["setuptools==80.9.0", "wheel==0.45.1"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
-packages = ["yieldskill"]
+packages = ["yieldskill", "yieldskill._runtime"]
[tool.setuptools.package-data]
yieldskill = ["_runtime/*"]
diff --git a/sdk/python/yieldskill/_runtime/__init__.py b/sdk/python/yieldskill/_runtime/__init__.py
new file mode 100644
index 0000000..9162fab
--- /dev/null
+++ b/sdk/python/yieldskill/_runtime/__init__.py
@@ -0,0 +1 @@
+"""Version-matched Yield runtime packaged by the release assembler."""
From 3cc8386518205a4afcc4e9edef935209a0406bf5 Mon Sep 17 00:00:00 2001
From: bigboateng
Date: Fri, 7 Aug 2026 21:19:59 +0100
Subject: [PATCH 2/2] Refresh evaluation evidence for Python packaging
---
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 563da73..f74d0ff 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-07T11:49:51.097Z",
- "source_digest": "d75c9a27b4782ff37a16c76b472327ecb6c8ab1b014dcf8e01626870340a82ac",
+ "generated_at": "2026-08-07T20:19:15.755Z",
+ "source_digest": "1695983e8279e9580deefe615b29814c8cc2777ce043cc73dd47caf992d7f269",
"status": "passed",
"workflow_conformance": {
"passed": 40,