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 @@

npm version + PyPI version Build status MIT license

@@ -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,