From 7dc4e90874ce9ac29fce25d9d6086a750ee1011c Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sat, 8 Aug 2026 09:17:54 +0100 Subject: [PATCH] Add self-hosted Yield release skill --- .agents/skills/release-yield/SKILL.md | 19 ++ .changeset/self-host-release.md | 5 + .claude/skills/release-yield/SKILL.md | 19 ++ .cursor/skills/release-yield/SKILL.md | 19 ++ .github/workflows/npm-publish.yml | 33 +++ .github/workflows/verify.yml | 16 +- README.md | 14 + package-lock.json | 120 +++++++-- package.json | 7 +- packaging/assemble.mjs | 1 + packaging/assemble.test.mjs | 11 + scripts/check-release-control.mjs | 5 +- scripts/prepare-selfhost.mjs | 25 ++ skills/release-yield/SKILL.md | 20 ++ skills/release-yield/controller.test.mjs | 24 ++ skills/release-yield/main.ts | 4 + .../scripts/release-controller.mjs | 249 ++++++++++++++++++ skills/release-yield/skill.json | 8 + skills/release-yield/workflow.test.mjs | 102 +++++++ skills/release-yield/workflow.ts | 140 ++++++++++ 20 files changed, 823 insertions(+), 18 deletions(-) create mode 100644 .agents/skills/release-yield/SKILL.md create mode 100644 .changeset/self-host-release.md create mode 100644 .claude/skills/release-yield/SKILL.md create mode 100644 .cursor/skills/release-yield/SKILL.md create mode 100644 scripts/prepare-selfhost.mjs create mode 100644 skills/release-yield/SKILL.md create mode 100644 skills/release-yield/controller.test.mjs create mode 100644 skills/release-yield/main.ts create mode 100644 skills/release-yield/scripts/release-controller.mjs create mode 100644 skills/release-yield/skill.json create mode 100644 skills/release-yield/workflow.test.mjs create mode 100644 skills/release-yield/workflow.ts diff --git a/.agents/skills/release-yield/SKILL.md b/.agents/skills/release-yield/SKILL.md new file mode 100644 index 0000000..97d5092 --- /dev/null +++ b/.agents/skills/release-yield/SKILL.md @@ -0,0 +1,19 @@ +--- +name: release-yield +description: "Release Yield through its protected GitHub workflows and verify every public registry." +--- + + + +This adapter exposes the canonical Yield workflow at `skills/release-yield`. +Read its SKILL.md, then run from the repository root: + + npm exec -- yskill run 'skills/release-yield' + + Follow each returned operation exactly. Answer each operation directly: + + npm exec -- yskill respond --value --skill 'skills/release-yield' + + For structured agent results, use --result-json instead of --value. + +Do not skip an operation or invent its response. diff --git a/.changeset/self-host-release.md b/.changeset/self-host-release.md new file mode 100644 index 0000000..fe4ec2c --- /dev/null +++ b/.changeset/self-host-release.md @@ -0,0 +1,5 @@ +--- +"@operatorstack/yield": patch +--- + +Preserve executable platform runtimes in npm packages and add the repository's protected self-hosted release workflow. diff --git a/.claude/skills/release-yield/SKILL.md b/.claude/skills/release-yield/SKILL.md new file mode 100644 index 0000000..97d5092 --- /dev/null +++ b/.claude/skills/release-yield/SKILL.md @@ -0,0 +1,19 @@ +--- +name: release-yield +description: "Release Yield through its protected GitHub workflows and verify every public registry." +--- + + + +This adapter exposes the canonical Yield workflow at `skills/release-yield`. +Read its SKILL.md, then run from the repository root: + + npm exec -- yskill run 'skills/release-yield' + + Follow each returned operation exactly. Answer each operation directly: + + npm exec -- yskill respond --value --skill 'skills/release-yield' + + For structured agent results, use --result-json instead of --value. + +Do not skip an operation or invent its response. diff --git a/.cursor/skills/release-yield/SKILL.md b/.cursor/skills/release-yield/SKILL.md new file mode 100644 index 0000000..97d5092 --- /dev/null +++ b/.cursor/skills/release-yield/SKILL.md @@ -0,0 +1,19 @@ +--- +name: release-yield +description: "Release Yield through its protected GitHub workflows and verify every public registry." +--- + + + +This adapter exposes the canonical Yield workflow at `skills/release-yield`. +Read its SKILL.md, then run from the repository root: + + npm exec -- yskill run 'skills/release-yield' + + Follow each returned operation exactly. Answer each operation directly: + + npm exec -- yskill respond --value --skill 'skills/release-yield' + + For structured agent results, use --result-json instead of --value. + +Do not skip an operation or invent its response. diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 70e5401..56c3bdf 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -353,3 +353,36 @@ jobs: name: crates-${{ needs.resolve.outputs.version }}-${{ needs.resolve.outputs.source_sha }} path: dist/crates-receipt/ if-no-files-found: error + + selfhost-canary: + name: Release skill against exact canary + needs: [resolve, npm] + if: needs.resolve.outputs.channel == 'canary' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + ref: ${{ needs.resolve.outputs.source_sha }} + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24" + package-manager-cache: false + - name: Install the exact published canary without changing repository state + env: + VERSION: ${{ needs.resolve.outputs.version }} + run: | + npm ci --ignore-scripts + npm install --no-save --package-lock=false --ignore-scripts "@operatorstack/yield@${VERSION}" + test "$(npm exec -- yskill version | awk '{print $2}')" = "$VERSION" + - name: Run the release skill contract against the canary SDK + run: | + npm run test:selfhost + journal="$RUNNER_TEMP/release-yield-journal.json" + output="$RUNNER_TEMP/release-yield-output.json" + printf '%s\n' '{"run_id":"canary-smoke","skill":{"name":"release-yield","digest":"sha256:canary"}}' > "$journal" + YIELD_JOURNAL="$journal" node skills/release-yield/main.ts > "$output" + node -e ' + const output = require(process.argv[1]); + if (output.type !== "request" || output.envelope.request.id !== "select-bump") process.exit(1); + ' "$output" diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 9be6d0a..b28e773 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -43,6 +43,20 @@ jobs: - run: npm run test:release - run: node scripts/check-release-control.mjs + selfhost: + name: Published SDK self-hosting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24" + cache: npm + - run: npm ci --ignore-scripts + - run: npm run prepare:selfhost + - run: npm run test:selfhost + - run: npm exec -- yskill doctor skills/release-yield --root . --agent codex,cursor,claude-code + typescript: name: TypeScript SDK and npm package runs-on: ubuntu-latest @@ -147,7 +161,7 @@ jobs: validate: name: Release authority and full validation if: ${{ always() }} - needs: [go, release, typescript, python, rust, conformance, examples] + needs: [go, release, selfhost, typescript, python, rust, conformance, examples] runs-on: ubuntu-latest steps: - name: Require every validation job diff --git a/README.md b/README.md index 15a9bb6..6c3ed2b 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,20 @@ Replace them with the test, publish, and registry commands for your project. The complete tested source is in [`examples/release-checklist`](https://github.com/operatorstack/yield/tree/main/examples/release-checklist/). + +## Yield releases Yield + +This repository uses its own exact published SDK for stable releases. The +canonical [`release-yield`](https://github.com/operatorstack/yield/tree/main/skills/release-yield) +workflow dispatches the protected GitHub release controller, records the human +authorization, waits through the npm, PyPI, and crates.io environments, and +verifies the Go module and final GitHub release. It never publishes from the +developer's computer. + +Every newly published canary runs the same contract tests in an isolated CI +lane. Stable release execution remains pinned to an exact public version. + + ## Use Yield in five steps ### 1. Install Yield diff --git a/package-lock.json b/package-lock.json index dd45d80..f57bd39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,8 +5,10 @@ "packages": { "": { "name": "@operatorstack/yield-repository", + "hasInstallScript": true, "devDependencies": { "@changesets/cli": "2.31.1", + "@operatorstack/yield": "0.1.38", "yaml": "2.9.0" } }, @@ -394,15 +396,110 @@ "node": ">= 8" } }, - "node_modules/@types/node": { - "version": "26.1.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", - "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", - "extraneous": true, + "node_modules/@operatorstack/yield": { + "version": "0.1.38", + "resolved": "https://registry.npmjs.org/@operatorstack/yield/-/yield-0.1.38.tgz", + "integrity": "sha512-s6RPEXLluzbvd2sHDGnDUNl9RoIYUz9nNWsgwJUoe7psRB3HaFR0v+aD66I8YsqTqW12ohy7Dir2+KrcAfE1Ww==", + "dev": true, "license": "MIT", - "dependencies": { - "undici-types": "~8.3.0" - } + "bin": { + "yskill": "bin/yskill.mjs" + }, + "engines": { + "node": ">=23.6" + }, + "optionalDependencies": { + "@operatorstack/yield-darwin-amd64": "0.1.38", + "@operatorstack/yield-darwin-arm64": "0.1.38", + "@operatorstack/yield-linux-amd64": "0.1.38", + "@operatorstack/yield-linux-arm64": "0.1.38", + "@operatorstack/yield-windows-amd64": "0.1.38", + "@operatorstack/yield-windows-arm64": "0.1.38" + } + }, + "node_modules/@operatorstack/yield-darwin-amd64": { + "version": "0.1.38", + "resolved": "https://registry.npmjs.org/@operatorstack/yield-darwin-amd64/-/yield-darwin-amd64-0.1.38.tgz", + "integrity": "sha512-UgW+b0Ik9jHMWQR9ZWDWecOyRnkVwkB5TjPrvMYjeoyCsx87UXWWwcWBjfhBL/rn6UID7J17abLiZJ1DQW14/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@operatorstack/yield-darwin-arm64": { + "version": "0.1.38", + "resolved": "https://registry.npmjs.org/@operatorstack/yield-darwin-arm64/-/yield-darwin-arm64-0.1.38.tgz", + "integrity": "sha512-UFcVCI7aowz3C/3C5NjBJydjLcoIedlfuppx4+KDU0oekQJuihdZfgfggRrv7Lm0+gUgEEBi2krsEWzn5hfLtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@operatorstack/yield-linux-amd64": { + "version": "0.1.38", + "resolved": "https://registry.npmjs.org/@operatorstack/yield-linux-amd64/-/yield-linux-amd64-0.1.38.tgz", + "integrity": "sha512-OLQ0bX748sj0I5D6kRpKXMK4f0V/Gn7IDTMi0tzT3nEFU8Yyt9953KAgFj8y/xS9HopEVcMbWgvdRwBunXhaFA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@operatorstack/yield-linux-arm64": { + "version": "0.1.38", + "resolved": "https://registry.npmjs.org/@operatorstack/yield-linux-arm64/-/yield-linux-arm64-0.1.38.tgz", + "integrity": "sha512-+iHijaRwFbxYpmTyN8ZOyvgMEIpxqx1UzaF9+zNdMpq9W556hlxY2kjntu6FyD6YnOEngXP8FdfaXrX52dKXew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@operatorstack/yield-windows-amd64": { + "version": "0.1.38", + "resolved": "https://registry.npmjs.org/@operatorstack/yield-windows-amd64/-/yield-windows-amd64-0.1.38.tgz", + "integrity": "sha512-Nw9hESRQReSGnFRZODJpCrWDMBlIULy1JngN/e3c+JZFL0Nmy3ddCJjVPyL0kUwW9pQdh1v+yrilAr2KYpbF/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@operatorstack/yield-windows-arm64": { + "version": "0.1.38", + "resolved": "https://registry.npmjs.org/@operatorstack/yield-windows-arm64/-/yield-windows-arm64-0.1.38.tgz", + "integrity": "sha512-NpFSxsOkyJZRiCw2mBg809rKfSLpTHbL7B+9ijMsY8zDsLR1rLis+6pfumak789dN2DR7HEOE2+q961vXUOcwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/ansi-colors": { "version": "4.1.3", @@ -1255,13 +1352,6 @@ "node": ">=8.0" } }, - "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "extraneous": true, - "license": "MIT" - }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", diff --git a/package.json b/package.json index 5278ae6..5648794 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,17 @@ { "name": "@operatorstack/yield-repository", "private": true, + "type": "module", "scripts": { "changeset": "changeset", + "postinstall": "node scripts/prepare-selfhost.mjs", + "prepare:selfhost": "node scripts/prepare-selfhost.mjs", "release:plan": "node scripts/release-plan.mjs", - "test:release": "node --test scripts/*.test.mjs packaging/*.test.mjs" + "test:release": "node --test scripts/*.test.mjs packaging/*.test.mjs", + "test:selfhost": "node --test skills/release-yield/*.test.mjs" }, "devDependencies": { + "@operatorstack/yield": "0.1.38", "@changesets/cli": "2.31.1", "yaml": "2.9.0" } diff --git a/packaging/assemble.mjs b/packaging/assemble.mjs index 28939c9..c1b7721 100644 --- a/packaging/assemble.mjs +++ b/packaging/assemble.mjs @@ -82,6 +82,7 @@ async function assembleNpm({ version, binaries, output }) { await writeFile(join(directory, "package.json"), `${JSON.stringify({ name: npmPackage(target), version, description: `Yield runtime for ${target.id}`, license: "MIT", os: [target.nodeOs], cpu: [target.nodeCpu], main: `./${runtime}`, + bin: { "yskill-runtime": `./${runtime}` }, files: [runtime, "LICENSE"], repository: { type: "git", url: "git+https://github.com/operatorstack/yield.git" }, homepage: "https://yield.operatorstack.systems/", bugs: { url: "https://github.com/operatorstack/yield/issues" }, diff --git a/packaging/assemble.test.mjs b/packaging/assemble.test.mjs index ed9b7b0..e4daa6a 100644 --- a/packaging/assemble.test.mjs +++ b/packaging/assemble.test.mjs @@ -1,5 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; import { access, mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -55,6 +56,10 @@ test("assembles one public npm package and six matching npm and Python runtimes" assert.ok(main.files.includes("assets")); assert.match(assembledReadme, /

Yield<\/h1>/); assert.match(await readFile(join(output, "npm/yield/LICENSE"), "utf8"), /MIT License/); + await assert.rejects(access(join(output, "npm/yield/skills/release-yield")), { code: "ENOENT" }); + await assert.rejects(access(join(output, "npm/yield/.agents")), { code: "ENOENT" }); + await assert.rejects(access(join(output, "npm/yield/.cursor")), { code: "ENOENT" }); + await assert.rejects(access(join(output, "npm/yield/.claude")), { code: "ENOENT" }); for (const target of targets) { const runtime = await readJson(join(output, `npm/${target.id}/package.json`)); @@ -63,7 +68,13 @@ test("assembles one public npm package and six matching npm and Python runtimes" assert.equal(runtime.homepage, homepage); assert.deepEqual(runtime.os, [target.nodeOs]); assert.deepEqual(runtime.cpu, [target.nodeCpu]); + assert.deepEqual(runtime.bin, { "yskill-runtime": `./${target.goos === "windows" ? "yskill.exe" : "yskill"}` }); assert.equal(runtime.publishConfig.provenance, true); + const packed = JSON.parse(execFileSync("npm", ["pack", "--dry-run", "--json"], { + cwd: join(output, `npm/${target.id}`), + encoding: "utf8", + })); + assert.equal(packed[0].files.find((file) => file.path === (target.goos === "windows" ? "yskill.exe" : "yskill")).mode, 0o755); assert.match(await readFile(join(output, `npm/${target.id}/LICENSE`), "utf8"), /MIT License/); const pythonRoot = join(output, `python/${target.id}`); diff --git a/scripts/check-release-control.mjs b/scripts/check-release-control.mjs index 21caf52..3daf363 100644 --- a/scripts/check-release-control.mjs +++ b/scripts/check-release-control.mjs @@ -44,7 +44,7 @@ export async function checkReleaseControl(root = resolve(import.meta.dirname, ". expect(!names.includes("sync-upstream.yml"), "projection sync workflow must be removed after graduation"); const verify = workflows["verify.yml"]; - const validationJobs = ["go", "release", "typescript", "python", "rust", "conformance", "examples"]; + const validationJobs = ["go", "release", "selfhost", "typescript", "python", "rust", "conformance", "examples"]; expect(verify, "verify.yml is required"); expect(verify.on?.pull_request !== undefined, "verification must run on every pull request"); expect(validationJobs.every((name) => verify.jobs?.[name]), "verification must expose every SDK and package boundary"); @@ -75,6 +75,9 @@ export async function checkReleaseControl(root = resolve(import.meta.dirname, ". 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"); + expect(publisher.jobs?.["selfhost-canary"]?.needs?.includes("npm"), "canary self-hosting must follow successful npm publication"); + expect(raw["npm-publish.yml"].includes('"@operatorstack/yield@${VERSION}"'), "canary self-hosting must install the exact resolved version"); + expect(!raw["npm-publish.yml"].includes("@operatorstack/yield@canary"), "canary self-hosting must not execute a floating dist-tag"); 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"); diff --git a/scripts/prepare-selfhost.mjs b/scripts/prepare-selfhost.mjs new file mode 100644 index 0000000..e1fd5c7 --- /dev/null +++ b/scripts/prepare-selfhost.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node +import { chmod, realpath, stat } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { resolve, sep } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const require = createRequire(import.meta.url); +const platforms = new Map([ + ["darwin:x64", "@operatorstack/yield-darwin-amd64"], + ["darwin:arm64", "@operatorstack/yield-darwin-arm64"], + ["linux:x64", "@operatorstack/yield-linux-amd64"], + ["linux:arm64", "@operatorstack/yield-linux-arm64"], + ["win32:x64", "@operatorstack/yield-windows-amd64"], + ["win32:arm64", "@operatorstack/yield-windows-arm64"], +]); + +const packageName = platforms.get(`${process.platform}:${process.arch}`); +if (!packageName) throw new Error(`unsupported self-host platform ${process.platform}/${process.arch}`); +const runtime = await realpath(require.resolve(packageName)); +const modules = await realpath(resolve(root, "node_modules")); +if (runtime !== modules && !runtime.startsWith(`${modules}${sep}`)) throw new Error("refusing to chmod a runtime outside this repository's node_modules"); +const details = await stat(runtime); +if (!details.isFile() || details.size === 0) throw new Error("published Yield runtime is missing or empty"); +if (process.platform !== "win32" && (details.mode & 0o111) === 0) await chmod(runtime, details.mode | 0o755); +process.stdout.write(`prepared ${packageName}\n`); diff --git a/skills/release-yield/SKILL.md b/skills/release-yield/SKILL.md new file mode 100644 index 0000000..f5e3d65 --- /dev/null +++ b/skills/release-yield/SKILL.md @@ -0,0 +1,20 @@ +--- +name: release-yield +description: Release Yield through its protected GitHub workflows and verify every public registry. +--- + +Use this workflow only from the `operatorstack/yield` repository. + +Run it from the repository root: + + npm exec -- yskill run skills/release-yield + +Follow every returned operation exactly. If the user already requested `auto`, +`patch`, `minor`, or `major`, use that value when the first choice appears. + +The workflow records one authorization for the exact version and source SHA, +then asks GitHub to enforce the repository's protected environments. It never +publishes packages, creates tags, or handles registry credentials locally. + +Choose **Finish after dry run** at the authorization step to complete with the +verified plan and stop before tags, approvals, or publication. diff --git a/skills/release-yield/controller.test.mjs b/skills/release-yield/controller.test.mjs new file mode 100644 index 0000000..8a88f10 --- /dev/null +++ b/skills/release-yield/controller.test.mjs @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Blocked, selectNewRun } from "./scripts/release-controller.mjs"; + +const runs = [ + { databaseId: 3, event: "workflow_dispatch", headSha: "abc" }, + { databaseId: 2, event: "push", headSha: "abc" }, + { databaseId: 1, event: "workflow_dispatch", headSha: "old" }, +]; + +test("correlates one new workflow dispatch at the exact source SHA", () => { + assert.equal(selectNewRun(runs, "1,2", { event: "workflow_dispatch", sourceSha: "abc" }).databaseId, 3); +}); + +test("returns null until a matching run appears", () => { + assert.equal(selectNewRun(runs, "1,2,3", { event: "workflow_dispatch", sourceSha: "abc" }), null); +}); + +test("refuses ambiguous new runs", () => { + assert.throws( + () => selectNewRun([...runs, { databaseId: 4, event: "workflow_dispatch", headSha: "abc" }], "1,2", { event: "workflow_dispatch", sourceSha: "abc" }), + Blocked, + ); +}); diff --git a/skills/release-yield/main.ts b/skills/release-yield/main.ts new file mode 100644 index 0000000..10f7d6b --- /dev/null +++ b/skills/release-yield/main.ts @@ -0,0 +1,4 @@ +import { defineSkill } from "@operatorstack/yield"; +import { runReleaseYield } from "./workflow.ts"; + +defineSkill(runReleaseYield); diff --git a/skills/release-yield/scripts/release-controller.mjs b/skills/release-yield/scripts/release-controller.mjs new file mode 100644 index 0000000..22ebdec --- /dev/null +++ b/skills/release-yield/scripts/release-controller.mjs @@ -0,0 +1,249 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { realpath } from "node:fs/promises"; +import { resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const repository = "operatorstack/yield"; +const root = resolve(import.meta.dirname, "../../.."); +const bumps = new Set(["auto", "patch", "minor", "major"]); +const active = new Set(["queued", "in_progress", "pending", "waiting", "requested"]); +const npmPackages = [ + "@operatorstack/yield", + "@operatorstack/yield-darwin-amd64", + "@operatorstack/yield-darwin-arm64", + "@operatorstack/yield-linux-amd64", + "@operatorstack/yield-linux-arm64", + "@operatorstack/yield-windows-amd64", + "@operatorstack/yield-windows-arm64", +]; +const crates = [ + "yieldskill", + "yieldskill-runtime-darwin-amd64", + "yieldskill-runtime-darwin-arm64", + "yieldskill-runtime-linux-amd64", + "yieldskill-runtime-linux-arm64", + "yieldskill-runtime-windows-amd64", + "yieldskill-runtime-windows-arm64", +]; + +export class Blocked extends Error {} +export class Failed extends Error {} + +function parseArgs(argv) { + const [action, ...rest] = argv; + if (!action) throw new Failed("an action is required"); + const values = {}; + for (let index = 0; index < rest.length; index += 2) { + if (!rest[index]?.startsWith("--") || rest[index + 1] === undefined) throw new Failed(`invalid argument ${rest[index] ?? ""}`); + values[rest[index].slice(2)] = rest[index + 1]; + } + return { action, values }; +} + +function run(file, args, options = {}) { + return execFileSync(file, args, { cwd: root, encoding: "utf8", stdio: [options.input ? "pipe" : "ignore", "pipe", "pipe"], ...options }).trim(); +} + +const git = (...args) => run("git", args); +const gh = (...args) => run("gh", args); +const ghJSON = (...args) => JSON.parse(gh(...args)); +const ghInput = (args, input) => run("gh", args, { input }); +const sleep = (milliseconds) => new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds)); + +export function selectNewRun(runs, baseline, { event, sourceSha } = {}) { + const previous = new Set(String(baseline || "").split(",").filter(Boolean)); + const candidates = runs.filter((run) => !previous.has(String(run.databaseId))) + .filter((run) => !event || run.event === event) + .filter((run) => !sourceSha || run.headSha === sourceSha); + if (candidates.length > 1) throw new Blocked("multiple matching workflow runs appeared; refusing ambiguous correlation"); + return candidates[0] ?? null; +} + +function listRuns(workflow) { + return ghJSON("run", "list", "--repo", repository, "--workflow", workflow, "--limit", "50", "--json", "databaseId,status,conclusion,headSha,event,createdAt,url,displayTitle"); +} + +const baseline = (workflow) => listRuns(workflow).map((run) => run.databaseId).join(",") || "none"; + +async function discover(workflow, previous, options = {}) { + for (let attempt = 0; attempt < 60; attempt += 1) { + const found = selectNewRun(listRuns(workflow), previous === "none" ? "" : previous, options); + if (found) return found; + await sleep(2000); + } + throw new Blocked(`no new ${workflow} run appeared`); +} + +function requireBump(value) { + if (!bumps.has(value)) throw new Failed(`invalid release bump ${value ?? "missing"}`); + return value; +} + +function normalizeRemote(value) { + return value.replace(/^git@github\.com:/, "https://github.com/").replace(/\.git$/, ""); +} + +function pendingDeployments(runID) { + return ghJSON("api", `repos/${repository}/actions/runs/${runID}/pending_deployments`); +} + +function approve(runID, deployments, expected) { + if (!deployments.length) return []; + const names = deployments.map((item) => item.environment?.name); + const unknown = names.filter((name) => !expected.has(name)); + if (unknown.length) throw new Failed(`unexpected protected environment: ${unknown.join(", ")}`); + const ids = deployments.map((item) => item.environment?.id); + try { + ghInput( + ["api", "--method", "POST", `repos/${repository}/actions/runs/${runID}/pending_deployments`, "--input", "-"], + JSON.stringify({ environment_ids: ids, state: "approved", comment: "Authorized by the recorded release-yield decision." }), + ); + } catch { + throw new Blocked(`GitHub refused approval for ${names.join(", ")}; approve the environments at the workflow run`); + } + return names; +} + +async function waitRun(runID, expectedEnvironments = new Set()) { + const seen = new Set(); + for (let attempt = 0; attempt < 1800; attempt += 1) { + for (const name of approve(runID, pendingDeployments(runID), expectedEnvironments)) seen.add(name); + const runInfo = ghJSON("run", "view", String(runID), "--repo", repository, "--json", "databaseId,status,conclusion,headSha,createdAt,updatedAt,url"); + if (runInfo.status === "completed") { + if (runInfo.conclusion !== "success") throw new Failed(`workflow run ${runID} concluded ${runInfo.conclusion}`); + return { ...runInfo, environments: [...seen].sort() }; + } + await sleep(2000); + } + throw new Blocked(`workflow run ${runID} did not complete before the timeout`); +} + +async function preflight(values) { + requireBump(values.bump); + if (normalizeRemote(git("remote", "get-url", "origin")) !== `https://github.com/${repository}`) throw new Blocked(`origin is not ${repository}`); + if (git("branch", "--show-current") !== "main") throw new Blocked("release-yield must run from the main branch"); + if (git("status", "--porcelain")) throw new Blocked("the worktree is not clean"); + git("fetch", "origin", "main"); + const sourceSha = git("rev-parse", "HEAD"); + if (git("rev-parse", "origin/main") !== sourceSha) throw new Blocked("main does not match origin/main"); + let protection; + try { + gh("auth", "status"); + protection = ghJSON("api", `repos/${repository}/branches/main/protection`); + } catch { + throw new Blocked("GitHub authentication cannot inspect the protected main branch"); + } + if (!protection?.enforce_admins?.enabled || !protection?.required_status_checks?.strict) throw new Blocked("main is not protected with strict required checks"); + const conflicting = [...listRuns("release.yml"), ...listRuns("npm-publish.yml")].filter((item) => active.has(item.status)); + if (conflicting.length) throw new Blocked(`another release workflow is active: ${conflicting[0].url}`); + return { source_sha: sourceSha, protected_main: true }; +} + +async function dispatch(values) { + const bump = requireBump(values.bump); + if (values["dry-run"] !== "true" && values["dry-run"] !== "false") throw new Failed("--dry-run must be true or false"); + const sourceSha = git("rev-parse", "HEAD"); + if (git("branch", "--show-current") !== "main" || git("status", "--porcelain")) throw new Blocked("main changed after preflight"); + if (git("rev-parse", "origin/main") !== sourceSha) throw new Blocked("source SHA changed after preflight"); + const conflicting = [...listRuns("release.yml"), ...listRuns("npm-publish.yml")].filter((item) => active.has(item.status)); + if (conflicting.length) throw new Blocked(`another release workflow is active: ${conflicting[0].url}`); + const releaseBaseline = baseline("release.yml"); + const publisherBaseline = baseline("npm-publish.yml"); + const finalizerBaseline = baseline("release-finalize.yml"); + try { + gh("workflow", "run", "release.yml", "--repo", repository, "--ref", "main", "-f", `bump=${bump}`, "-f", `dry_run=${values["dry-run"]}`); + } catch { + throw new Blocked("GitHub refused the release workflow dispatch"); + } + const found = await discover("release.yml", releaseBaseline, { event: "workflow_dispatch", sourceSha }); + return { + source_sha: sourceSha, + run_id: String(found.databaseId), + run_url: found.url, + publisher_baseline: publisherBaseline, + finalizer_baseline: finalizerBaseline, + }; +} + +async function plan(values) { + const bump = requireBump(values.bump); + const value = JSON.parse(run("node", ["scripts/release-plan.mjs", "--bump", bump])); + return { version: value.version, tag: `v${value.version}`, source_sha: value.sourceSha, changesets: value.changesets }; +} + +async function monitorController(values) { + const info = await waitRun(values["run-id"], new Set(["release-control"])); + const publisher = await discover("npm-publish.yml", values["publisher-baseline"] === "none" ? "" : values["publisher-baseline"], { event: "workflow_dispatch" }); + return { run_id: String(info.databaseId), run_url: info.url, publisher_run_id: String(publisher.databaseId), publisher_run_url: publisher.url, environments: info.environments }; +} + +async function monitorPublisher(values) { + const info = await waitRun(values["run-id"], new Set(["npm-production", "pypi-production", "crates-production"])); + return { run_id: String(info.databaseId), run_url: info.url, environments: info.environments }; +} + +async function monitorFinalizer(values) { + const found = await discover("release-finalize.yml", values.baseline === "none" ? "" : values.baseline, { event: "workflow_run" }); + const info = await waitRun(String(found.databaseId)); + return { run_id: String(info.databaseId), run_url: info.url }; +} + +async function verify(values) { + const { version, tag } = values; + const sourceSha = values["source-sha"]; + if (!/^\d+\.\d+\.\d+$/.test(version ?? "") || tag !== `v${version}` || !/^[0-9a-f]{40}$/.test(sourceSha ?? "")) throw new Failed("invalid release identity"); + for (const name of npmPackages) { + const found = JSON.parse(run("npm", ["view", `${name}@${version}`, "version", "--json"])); + if (found !== version) throw new Failed(`${name}@${version} is missing from npm`); + } + const python = await (await fetch(`https://pypi.org/pypi/yieldskill/${version}/json`)).json(); + if (python?.info?.version !== version || !Array.isArray(python.urls) || python.urls.length !== 6 || python.urls.some((file) => !file?.digests?.sha256)) throw new Failed(`yieldskill ${version} is incomplete on PyPI`); + for (const name of crates) { + const response = await fetch(`https://crates.io/api/v1/crates/${name}/${version}`); + const body = await response.json(); + if (!response.ok || body?.version?.num !== version) throw new Failed(`${name}@${version} is missing from crates.io`); + } + run("node", ["packaging/go-release.mjs", "--version", version, "--source-sha", sourceSha, "--attempts", "3", "--delay-ms", "10000"]); + const remoteTag = git("ls-remote", "--tags", "origin", `refs/tags/${tag}`).split(/\s+/)[0]; + if (remoteTag !== sourceSha) throw new Failed(`${tag} does not point to the authorized source SHA`); + const release = ghJSON("release", "view", tag, "--repo", repository, "--json", "isDraft,tagName,url,targetCommitish"); + if (release.isDraft || release.tagName !== tag) throw new Failed(`${tag} is not a finalized GitHub release`); + return { + targets: { + npm: npmPackages.length, + pypi: { project: "yieldskill", wheels: python.urls.length }, + crates: crates.length, + go: "github.com/operatorstack/yield", + github: release.url, + }, + }; +} + +export async function execute(action, values) { + if (action === "preflight") return preflight(values); + if (action === "dispatch") return dispatch(values); + if (action === "wait") { + const info = await waitRun(values["run-id"]); + return { run_id: String(info.databaseId), run_url: info.url, source_sha: info.headSha }; + } + if (action === "plan") return plan(values); + if (action === "monitor-controller") return monitorController(values); + if (action === "monitor-publisher") return monitorPublisher(values); + if (action === "monitor-finalizer") return monitorFinalizer(values); + if (action === "verify") return verify(values); + throw new Failed(`unknown action ${action}`); +} + +async function main() { + try { + const { action, values } = parseArgs(process.argv.slice(2)); + process.stdout.write(`${JSON.stringify({ status: "ok", ...(await execute(action, values)) })}\n`); + } catch (error) { + const status = error instanceof Blocked ? "blocked" : "failed"; + process.stdout.write(`${JSON.stringify({ status, reason: error instanceof Error ? error.message : String(error) })}\n`); + } +} + +if (process.argv[1] && await realpath(process.argv[1]) === await realpath(fileURLToPath(import.meta.url))) await main(); diff --git a/skills/release-yield/skill.json b/skills/release-yield/skill.json new file mode 100644 index 0000000..a09b572 --- /dev/null +++ b/skills/release-yield/skill.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "language": "typescript", + "run": [ + "node", + "main.ts" + ] +} diff --git a/skills/release-yield/workflow.test.mjs b/skills/release-yield/workflow.test.mjs new file mode 100644 index 0000000..5c489f8 --- /dev/null +++ b/skills/release-yield/workflow.test.mjs @@ -0,0 +1,102 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { runReleaseYield } from "./workflow.ts"; + +const sha = "a".repeat(40); + +function successReceipts(overrides = {}) { + return { + preflight: { status: "ok", source_sha: sha }, + "dispatch-dry-run": { status: "ok", source_sha: sha, run_id: "10", run_url: "https://example.test/10" }, + "wait-dry-run": { status: "ok", source_sha: sha, run_id: "10", run_url: "https://example.test/10" }, + "resolve-plan": { status: "ok", source_sha: sha, version: "1.2.3", tag: "v1.2.3" }, + "dispatch-release": { + status: "ok", source_sha: sha, run_id: "11", run_url: "https://example.test/11", + publisher_baseline: "1,2", finalizer_baseline: "3,4", + }, + "wait-release-control": { + status: "ok", run_id: "11", run_url: "https://example.test/11", + publisher_run_id: "12", publisher_run_url: "https://example.test/12", + }, + "wait-publishers": { status: "ok", run_id: "12", run_url: "https://example.test/12" }, + "wait-finalizer": { status: "ok", run_id: "13", run_url: "https://example.test/13" }, + "verify-public-release": { + status: "ok", + targets: { npm: 7, pypi: { project: "yieldskill", wheels: 6 }, crates: 7, go: "github.com/operatorstack/yield" }, + }, + ...overrides, + }; +} + +function context({ authorization = "release", receipts = successReceipts() } = {}) { + const operations = []; + return { + operations, + askUser(id) { + operations.push(id); + return id === "select-bump" ? "patch" : authorization; + }, + runCommand(id) { + operations.push(id); + const receipt = receipts[id]; + assert.ok(receipt, `missing receipt for ${id}`); + return { exit_code: 0, stdout: JSON.stringify(receipt), stderr: "" }; + }, + require(ok, claim) { + if (!ok) throw new Error(`requirement_failed: ${claim}`); + }, + blocked(reason) { + throw new Error(`blocked: ${reason}`); + }, + refused(reason) { + throw new Error(`refused: ${reason}`); + }, + }; +} + +test("enforces dry run, immutable authorization, protected publication, and verification order", () => { + const ctx = context(); + const result = runReleaseYield(ctx); + assert.deepEqual(ctx.operations, [ + "select-bump", "preflight", "dispatch-dry-run", "wait-dry-run", "resolve-plan", "authorize-release", + "dispatch-release", "wait-release-control", "wait-publishers", "wait-finalizer", "verify-public-release", + ]); + assert.equal(result.version, "1.2.3"); + assert.equal(result.source_sha, sha); + assert.equal(result.verified.npm, 7); +}); + +test("stops before live dispatch when authorization is declined", () => { + const ctx = context({ authorization: "stop" }); + assert.throws(() => runReleaseYield(ctx), /refused: release of v1\.2\.3 was not authorized/); + assert.equal(ctx.operations.includes("dispatch-release"), false); +}); + +test("completes successfully after the verified dry run without dispatching a release", () => { + const ctx = context({ authorization: "dry-run" }); + const result = runReleaseYield(ctx); + assert.equal(result.mode, "dry-run"); + assert.equal(result.version, "1.2.3"); + assert.equal(ctx.operations.includes("dispatch-release"), false); +}); + +test("reports a GitHub authority boundary as blocked", () => { + const ctx = context({ receipts: successReceipts({ preflight: { status: "blocked", reason: "GitHub denied workflow dispatch" } }) }); + assert.throws(() => runReleaseYield(ctx), /blocked: GitHub denied workflow dispatch/); + assert.deepEqual(ctx.operations, ["select-bump", "preflight"]); +}); + +test("refuses plan drift before authorization", () => { + const ctx = context({ receipts: successReceipts({ "resolve-plan": { status: "ok", source_sha: "b".repeat(40), version: "1.2.3", tag: "v1.2.3" } }) }); + assert.throws(() => runReleaseYield(ctx), /displayed plan uses the dry-run source SHA/); + assert.equal(ctx.operations.includes("authorize-release"), false); +}); + +test("rejects malformed controller receipts", () => { + const ctx = context(); + ctx.runCommand = (id) => { + ctx.operations.push(id); + return { exit_code: 0, stdout: "not-json", stderr: "" }; + }; + assert.throws(() => runReleaseYield(ctx), /controller returned invalid JSON/); +}); diff --git a/skills/release-yield/workflow.ts b/skills/release-yield/workflow.ts new file mode 100644 index 0000000..0296a6c --- /dev/null +++ b/skills/release-yield/workflow.ts @@ -0,0 +1,140 @@ +import type { CommandResult, Context } from "@operatorstack/yield"; + +export type ReleaseBump = "auto" | "patch" | "minor" | "major"; + +type Receipt = { + status: "ok" | "blocked" | "failed"; + reason?: string; + [key: string]: unknown; +}; + +type ReleaseContext = Pick; + +const controller = "node scripts/release-controller.mjs"; + +function parseReceipt(ctx: ReleaseContext, claim: string, result: CommandResult): Receipt { + ctx.require(result.exit_code === 0 && !result.timed_out, claim, result); + let receipt: Receipt; + try { + receipt = JSON.parse(result.stdout.trim()) as Receipt; + } catch { + ctx.require(false, `${claim}: controller returned invalid JSON`, result); + throw new Error("unreachable"); + } + if (receipt.status === "blocked") ctx.blocked(receipt.reason ?? claim); + ctx.require(receipt.status === "ok", receipt.reason ?? claim, receipt); + return receipt; +} + +function command(ctx: ReleaseContext, id: string, args: string, claim: string, timeout = 600): Receipt { + return parseReceipt(ctx, claim, ctx.runCommand(id, `${controller} ${args}`, timeout)); +} + +function stringField(ctx: ReleaseContext, receipt: Receipt, field: string): string { + const value = receipt[field]; + ctx.require(typeof value === "string" && value.length > 0, `controller receipt contains ${field}`, receipt); + return value as string; +} + +function matchingField(ctx: ReleaseContext, receipt: Receipt, field: string, pattern: RegExp): string { + const value = stringField(ctx, receipt, field); + ctx.require(pattern.test(value), `controller receipt contains a valid ${field}`, receipt); + return value; +} + +export function runReleaseYield(ctx: ReleaseContext) { + const bump = ctx.askUser("select-bump", "Choose the Yield release bump.", [ + { value: "auto", label: "Use Changesets" }, + { value: "patch", label: "Patch" }, + { value: "minor", label: "Minor" }, + { value: "major", label: "Major" }, + ]) as ReleaseBump; + + const preflight = command(ctx, "preflight", `preflight --bump ${bump}`, "the protected main preflight passes"); + const sourceSha = matchingField(ctx, preflight, "source_sha", /^[0-9a-f]{40}$/); + + const dry = command(ctx, "dispatch-dry-run", `dispatch --bump ${bump} --dry-run true`, "the dry-run workflow is dispatched"); + const dryRunID = matchingField(ctx, dry, "run_id", /^\d+$/); + const dryResult = command(ctx, "wait-dry-run", `wait --run-id ${dryRunID}`, "the GitHub dry run succeeds", 1800); + ctx.require(stringField(ctx, dryResult, "source_sha") === sourceSha, "the dry run uses the preflight source SHA", dryResult); + + const plan = command(ctx, "resolve-plan", `plan --bump ${bump}`, "the local deterministic release plan resolves"); + const version = matchingField(ctx, plan, "version", /^\d+\.\d+\.\d+$/); + const tag = matchingField(ctx, plan, "tag", /^v\d+\.\d+\.\d+$/); + ctx.require(tag === `v${version}`, "the release tag matches the planned version", plan); + ctx.require(stringField(ctx, plan, "source_sha") === sourceSha, "the displayed plan uses the dry-run source SHA", plan); + + const authorization = ctx.askUser( + "authorize-release", + `Dry run passed for ${tag} from ${sourceSha}. Continue with the protected release?`, + [ + { value: "release", label: `Release ${tag}` }, + { value: "dry-run", label: "Finish after dry run" }, + { value: "stop", label: "Stop" }, + ], + ); + if (authorization === "dry-run") { + return { + mode: "dry-run", + bump, + version, + tag, + source_sha: sourceSha, + dry_run: { id: dryRunID, url: dry.run_url }, + }; + } + if (authorization !== "release") ctx.refused(`release of ${tag} was not authorized`); + + const live = command(ctx, "dispatch-release", `dispatch --bump ${bump} --dry-run false`, "the protected release workflow is dispatched"); + ctx.require(stringField(ctx, live, "source_sha") === sourceSha, "the live dispatch uses the authorized source SHA", live); + const releaseRunID = matchingField(ctx, live, "run_id", /^\d+$/); + const publisherBaseline = matchingField(ctx, live, "publisher_baseline", /^(?:none|\d+(?:,\d+)*)$/); + const finalizerBaseline = matchingField(ctx, live, "finalizer_baseline", /^(?:none|\d+(?:,\d+)*)$/); + + const release = command( + ctx, + "wait-release-control", + `monitor-controller --run-id ${releaseRunID} --publisher-baseline ${publisherBaseline}`, + "release-control approves and the controller dispatches the publisher", + 3600, + ); + const publisherRunID = matchingField(ctx, release, "publisher_run_id", /^\d+$/); + + command( + ctx, + "wait-publishers", + `monitor-publisher --run-id ${publisherRunID}`, + "npm, PyPI, and crates.io publishers complete", + 3600, + ); + + const finalized = command( + ctx, + "wait-finalizer", + `monitor-finalizer --baseline ${finalizerBaseline}`, + "the release finalizer completes", + 1800, + ); + const finalizerRunID = matchingField(ctx, finalized, "run_id", /^\d+$/); + + const verified = command( + ctx, + "verify-public-release", + `verify --version ${version} --tag ${tag} --source-sha ${sourceSha}`, + "every public release target matches the authorized release", + 1800, + ); + + return { + mode: "release", + bump, + version, + tag, + source_sha: sourceSha, + dry_run: { id: dryRunID, url: dry.run_url }, + release_controller: { id: releaseRunID, url: live.run_url }, + publisher: { id: publisherRunID, url: release.publisher_run_url }, + finalizer: { id: finalizerRunID, url: finalized.run_url }, + verified: verified.targets, + }; +}