diff --git a/.agents/skills/release-yield/SKILL.md b/.agents/skills/release-yield/SKILL.md index 9e98efa..c9c4fa0 100644 --- a/.agents/skills/release-yield/SKILL.md +++ b/.agents/skills/release-yield/SKILL.md @@ -3,7 +3,7 @@ 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: diff --git a/.claude/skills/release-yield/SKILL.md b/.claude/skills/release-yield/SKILL.md index 9e98efa..c9c4fa0 100644 --- a/.claude/skills/release-yield/SKILL.md +++ b/.claude/skills/release-yield/SKILL.md @@ -3,7 +3,7 @@ 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: diff --git a/.cursor/skills/release-yield/SKILL.md b/.cursor/skills/release-yield/SKILL.md index 9e98efa..c9c4fa0 100644 --- a/.cursor/skills/release-yield/SKILL.md +++ b/.cursor/skills/release-yield/SKILL.md @@ -3,7 +3,7 @@ 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: diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ac31a03 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,28 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{js,mjs,cjs,ts,tsx,json,md,yaml,yml,toml}] +indent_style = space +indent_size = 2 + +[*.py] +indent_style = space +indent_size = 4 + +[*.go] +indent_style = tab + +[*.rs] +indent_style = space +indent_size = 4 + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 3852552..137e3e1 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -13,6 +13,26 @@ concurrency: cancel-in-progress: true jobs: + format: + name: Repository formatting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: go.mod + cache: true + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24" + cache: npm + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + - run: npm ci --ignore-scripts + - run: python -m pip install uv==0.12.3 + - run: npm run format:check + go: name: Go and agent registration (${{ matrix.os }}) strategy: @@ -169,7 +189,7 @@ jobs: validate: name: Release authority and full validation if: ${{ always() }} - needs: [go, release, selfhost, typescript, python, rust, conformance, examples] + needs: [format, go, release, selfhost, typescript, python, rust, conformance, examples] runs-on: ubuntu-latest steps: - name: Require every validation job diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..e23b501 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,21 @@ +.yield/ +.agents/ +.claude/ +.cursor/ +node_modules/ +dist/ +build/ +target/ +evals/runs/ +evals/results/ +evals/agent/ +examples/library/go/ +examples/library/python/ +examples/library/rust/ +examples/library/typescript/ +examples/library/catalog.json +internal/ +sdk/typescript/src/index.ts +package-lock.json +evals/package-lock.json +sdk/typescript/package-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..fea6c48 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "printWidth": 100, + "proseWrap": "preserve", + "semi": false, + "singleQuote": false, + "trailingComma": "all" +} diff --git a/README.md b/README.md index b3debfb..a2c9526 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,12 @@ available for 73 more coding agents. Run the command for your project: -| Language | Command | -|---|---| -| TypeScript | `npm create @operatorstack/yield@latest` | -| Python | `uvx --from yieldskill yskill bootstrap --language python` | -| Rust | `cargo install yieldskill --locked`, then `yskill bootstrap --language rust` | -| Go | `go run github.com/operatorstack/yield/cmd/yskill@latest bootstrap --language go` | +| Language | Command | +| ---------- | --------------------------------------------------------------------------------- | +| TypeScript | `npm create @operatorstack/yield@latest` | +| Python | `uvx --from yieldskill yskill bootstrap --language python` | +| Rust | `cargo install yieldskill --locked`, then `yskill bootstrap --language rust` | +| Go | `go run github.com/operatorstack/yield/cmd/yskill@latest bootstrap --language go` | Yield detects the repository, language, and installed coding agents. It shows every proposed file and dependency change. It asks before it writes. It then @@ -84,17 +84,18 @@ A release skill often starts as prose: Yield makes the order and stopping rules executable: + ```typescript -import { defineSkill } from "@operatorstack/yield"; +import { defineSkill } from "@operatorstack/yield" -type Review = { critical: number; summary: string }; +type Review = { critical: number; summary: string } defineSkill((ctx) => { // Yield runs commands itself and records their output and exit status. - const tests = ctx.runCommand("test", "echo tests-ok", 300); + const tests = ctx.runCommand("test", "echo tests-ok", 300) // A failed requirement stops the workflow and keeps its evidence. - ctx.require(tests.exit_code === 0, "the test command succeeds", tests); + ctx.require(tests.exit_code === 0, "the test command succeeds", tests) // Review gives TypeScript its compile-time type. The JSON schema checks the // coding agent's response at runtime before this workflow can continue. @@ -110,28 +111,29 @@ defineSkill((ctx) => { summary: { type: "string", minLength: 1 }, }, }, - ); - ctx.require(review.critical === 0, "the review has no critical findings", review); + ) + ctx.require(review.critical === 0, "the review has no critical findings", review) // Yield emits these fixed choices. A supported host may show native controls; // otherwise the coding agent asks through its normal interface. const approval = ctx.askUser("approve-publish", "Publish this package?", [ { value: "yes", label: "Publish" }, { value: "no", label: "Stop" }, - ]); - if (approval !== "yes") ctx.refused("the operator declined publication"); + ]) + if (approval !== "yes") ctx.refused("the operator declined publication") // Publishing cannot start before approval. Verification is a separate step, // so completion requires evidence that the registry contains the release. - const publish = ctx.runCommand("publish", "echo publish-ok", 600); - ctx.require(publish.exit_code === 0, "the publish command succeeds", publish); + const publish = ctx.runCommand("publish", "echo publish-ok", 600) + ctx.require(publish.exit_code === 0, "the publish command succeeds", publish) - const registry = ctx.runCommand("verify-registry", "echo registry-ok", 300); - ctx.require(registry.exit_code === 0, "the registry contains the release", registry); + const registry = ctx.runCommand("verify-registry", "echo registry-ok", 300) + ctx.require(registry.exit_code === 0, "the registry contains the release", registry) - return { published: true, summary: review.summary }; -}); + return { published: true, summary: review.summary } +}) ``` + The example uses harmless commands so its fixture can run in any checkout. @@ -140,6 +142,7 @@ 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 @@ -263,13 +266,13 @@ The agent follows the generated adapter, runs the canonical workflow in If replay produces a different operation, the run fails instead of silently forking. Every side effect crosses one of these primitives: -| Primitive | Purpose | -|---|---| -| `runCommand` | Execute a command and record its exit code and output. | -| `agentTask` | Ask the coding agent for schema-valid JSON. | -| `askUser` | Request an explicit human decision. | -| `require` | Bind a required claim to recorded evidence. | -| `blocked` / `refused` | Stop honestly when work cannot or must not continue. | +| Primitive | Purpose | +| --------------------- | ------------------------------------------------------ | +| `runCommand` | Execute a command and record its exit code and output. | +| `agentTask` | Ask the coding agent for schema-valid JSON. | +| `askUser` | Request an explicit human decision. | +| `require` | Bind a required claim to recorded evidence. | +| `blocked` / `refused` | Stop honestly when work cannot or must not continue. | See the [primitive guides](https://github.com/operatorstack/yield/blob/main/docs/primitives/README.md) and [runtime reference](https://github.com/operatorstack/yield/blob/main/docs/reference/cli.md) for the full contract. @@ -279,12 +282,12 @@ See the [primitive guides](https://github.com/operatorstack/yield/blob/main/docs All four SDKs implement the same execution contract. The conformance suite runs the same program in every language and compares observable behavior. -| Language | SDK | Example | -|---|---|---| -| TypeScript | [`@operatorstack/yield`](https://github.com/operatorstack/yield/tree/main/sdk/typescript/) | [`release-checklist`](https://github.com/operatorstack/yield/tree/main/examples/release-checklist/) | -| Python | [`yieldskill`](https://github.com/operatorstack/yield/tree/main/sdk/python/) | [`env-doctor`](https://github.com/operatorstack/yield/tree/main/examples/env-doctor/) | -| Go | [`github.com/operatorstack/yield/sdk/yield`](https://pkg.go.dev/github.com/operatorstack/yield/sdk/yield) | [`investigate`](https://github.com/operatorstack/yield/tree/main/examples/investigate/) | -| Rust | [`yieldskill`](https://github.com/operatorstack/yield/tree/main/sdk/rust/) | [`data-migration`](https://github.com/operatorstack/yield/tree/main/examples/data-migration/) | +| Language | SDK | Example | +| ---------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| TypeScript | [`@operatorstack/yield`](https://github.com/operatorstack/yield/tree/main/sdk/typescript/) | [`release-checklist`](https://github.com/operatorstack/yield/tree/main/examples/release-checklist/) | +| Python | [`yieldskill`](https://github.com/operatorstack/yield/tree/main/sdk/python/) | [`env-doctor`](https://github.com/operatorstack/yield/tree/main/examples/env-doctor/) | +| Go | [`github.com/operatorstack/yield/sdk/yield`](https://pkg.go.dev/github.com/operatorstack/yield/sdk/yield) | [`investigate`](https://github.com/operatorstack/yield/tree/main/examples/investigate/) | +| Rust | [`yieldskill`](https://github.com/operatorstack/yield/tree/main/sdk/rust/) | [`data-migration`](https://github.com/operatorstack/yield/tree/main/examples/data-migration/) | Cursor, Codex, and Claude Code are verified integrations. Yield also includes registry-backed project paths for 73 more coding agents. Those paths support @@ -318,10 +321,16 @@ loop, multi-agent orchestrator, or security sandbox. Run the main checks from the repository root: ```bash +npm run format:check go test ./... npm run test:release ``` +Run `npm run format` to format the supported source files. Install the repository +npm dependencies first. The command also needs Go, Rust, and `uvx`. Generated +files and evaluation sources with byte-bound receipts stay unchanged until their +generators or evaluations run. + The [example library](https://github.com/operatorstack/yield/tree/main/examples/library/) contains ten common workflows in all four SDKs, including code review, failure investigation, CI repair, dependency updates, database migration, security audit, and package release. diff --git a/docs/README.md b/docs/README.md index 8c49ac3..0fc6a98 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,7 @@ # Yield documentation +- [Repository formatting](formatting.md) + Move repeatable coding-agent instructions from words into code. The [public documentation](https://yield.operatorstack.systems/docs/) is the @@ -42,13 +44,13 @@ and start it. ## The split to remember -| Put in code | Leave with the model | -|---|---| -| order and branching | investigation and judgment | -| retry limits | reading unfamiliar code | -| commands that must really run | proposing changes | -| approval points | writing explanations | -| evidence required to finish | interpreting evidence | +| Put in code | Leave with the model | +| ----------------------------- | -------------------------- | +| order and branching | investigation and judgment | +| retry limits | reading unfamiliar code | +| commands that must really run | proposing changes | +| approval points | writing explanations | +| evidence required to finish | interpreting evidence | This is not a new agent loop or a hosted agent runtime. A thin `SKILL.md` starts the program, the program emits one typed operation, and the coding agent diff --git a/docs/agent-setup.md b/docs/agent-setup.md index 8d5e0cb..5cd8d10 100644 --- a/docs/agent-setup.md +++ b/docs/agent-setup.md @@ -26,12 +26,12 @@ yskill register-all skills --agent cursor,codex,claude-code --prune Use the launcher installed by the selected language package: -| Language | Launcher | -|---|---| -| TypeScript | `npm exec -- yskill` | -| Python | `python -m yieldskill` | -| Go | `.yield/bin/yskill` | -| Rust | `.yield/bin/yskill` | +| Language | Launcher | +| ---------- | ---------------------- | +| TypeScript | `npm exec -- yskill` | +| Python | `python -m yieldskill` | +| Go | `.yield/bin/yskill` | +| Rust | `.yield/bin/yskill` | Go and Rust keep one version-locked runtime in `.yield/bin` at the repository root. Registration checks that runtime, the workflow SDK, and the generated @@ -117,4 +117,7 @@ Workflow-only `doctor` works without `.git`. A Go or Rust runtime under `.yield/bin` also identifies the project root for `init`, `doctor`, and registration. For other non-Git layouts, pass `--root` so Yield knows where agent adapters belong. + +``` + ``` diff --git a/docs/examples.md b/docs/examples.md index cbf3ef1..37f6dc8 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -4,18 +4,18 @@ The library contains ten common skill workflows for coding agents. Every workflo implemented in TypeScript, Python, Go, and Rust, so the first choice is your repository's language—not which example happens to exist. -| Skill workflow | Control flow moved into code | -|---|---| -| [Review a branch](../examples/library/typescript/review-branch/) | checks, review, zero-critical gate | -| [Investigate a failure](../examples/library/typescript/investigate-failure/) | evidence, diagnosis, supported cause | -| [QA a web change](../examples/library/typescript/qa-web-change/) | build, changed-route QA, no-blocker gate | -| [Release a package](../examples/library/typescript/release-package/) | tests, review, approval, publish, verify | -| [Triage an issue](../examples/library/typescript/triage-issue/) | read, classify, one next action | -| [Repair CI](../examples/library/typescript/repair-ci/) | failed log, supported repair, rerun | -| [Upgrade a dependency](../examples/library/typescript/upgrade-dependency/) | baseline, compatibility review, approval, update, tests | -| [Run a database migration](../examples/library/typescript/migrate-database/) | dry-run, risk review, approval, apply, verify | -| [Audit security](../examples/library/typescript/audit-security/) | mechanical scans, trust-boundary review, zero-critical gate | -| [Publish an iOS build](../examples/library/typescript/publish-ios/) | archive, metadata review, approval, upload, processing check | +| Skill workflow | Control flow moved into code | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------ | +| [Review a branch](../examples/library/typescript/review-branch/) | checks, review, zero-critical gate | +| [Investigate a failure](../examples/library/typescript/investigate-failure/) | evidence, diagnosis, supported cause | +| [QA a web change](../examples/library/typescript/qa-web-change/) | build, changed-route QA, no-blocker gate | +| [Release a package](../examples/library/typescript/release-package/) | tests, review, approval, publish, verify | +| [Triage an issue](../examples/library/typescript/triage-issue/) | read, classify, one next action | +| [Repair CI](../examples/library/typescript/repair-ci/) | failed log, supported repair, rerun | +| [Upgrade a dependency](../examples/library/typescript/upgrade-dependency/) | baseline, compatibility review, approval, update, tests | +| [Run a database migration](../examples/library/typescript/migrate-database/) | dry-run, risk review, approval, apply, verify | +| [Audit security](../examples/library/typescript/audit-security/) | mechanical scans, trust-boundary review, zero-critical gate | +| [Publish an iOS build](../examples/library/typescript/publish-ios/) | archive, metadata review, approval, upload, processing check | Change the language segment in any link to python, go, or rust. Source files are grouped separately for fast browsing: @@ -38,13 +38,13 @@ repository. Replace them with project commands before adopting a skill workflow. These examples show longer programs with a thin `SKILL.md` and scripted responses under `fixtures/responses.json`. -| Example | Language | Pattern | -|---|---|---| -| [`release-checklist`](../examples/release-checklist/) | TypeScript | approval, build, model-authored notes, publish, verify | -| [`env-doctor`](../examples/env-doctor/) | Python | probe, diagnose, wait for a person, recheck | -| [`investigate`](../examples/investigate/) | Go | structured hypotheses, real probes, bounded attempts | -| [`data-migration`](../examples/data-migration/) | Rust | dry-run, approval, apply, verify | -| [`convert-skill`](../examples/convert-skill/) | Go | extract a prose workflow, generate code, execute its fixtures | +| Example | Language | Pattern | +| ----------------------------------------------------- | ---------- | ------------------------------------------------------------- | +| [`release-checklist`](../examples/release-checklist/) | TypeScript | approval, build, model-authored notes, publish, verify | +| [`env-doctor`](../examples/env-doctor/) | Python | probe, diagnose, wait for a person, recheck | +| [`investigate`](../examples/investigate/) | Go | structured hypotheses, real probes, bounded attempts | +| [`data-migration`](../examples/data-migration/) | Rust | dry-run, approval, apply, verify | +| [`convert-skill`](../examples/convert-skill/) | Go | extract a prose workflow, generate code, execute its fixtures | From the repository root: diff --git a/docs/formatting.md b/docs/formatting.md new file mode 100644 index 0000000..06fe456 --- /dev/null +++ b/docs/formatting.md @@ -0,0 +1,31 @@ +# Repository formatting + +Run this command before you open a pull request: + +```bash +npm run format +``` + +Run this command to check formatting without changing files: + +```bash +npm run format:check +``` + +Yield uses the standard formatter for each source type: + +| Source | Formatter | +| ------------------------------------------------ | ------------------------------------------------------------------ | +| JavaScript, TypeScript, JSON, Markdown, and YAML | [Prettier](https://prettier.io/docs/install.html) | +| Python | [Ruff](https://docs.astral.sh/ruff/formatter/) | +| Go | [gofmt](https://pkg.go.dev/cmd/gofmt) | +| Rust | [rustfmt](https://doc.rust-lang.org/cargo/commands/cargo-fmt.html) | +| Shell | [shfmt](https://github.com/mvdan/sh) | +| TOML | [Taplo](https://taplo.tamasfe.dev/cli/introduction.html) | + +The command pins third-party formatter versions. Go and Rust use the repository +toolchain versions. GitHub Actions runs the check and does not rewrite files. + +The formatter skips generated files. It also skips evaluation sources whose +exact bytes belong to a committed receipt. Run the relevant generator or +evaluation when you change those sources. diff --git a/docs/primitives/README.md b/docs/primitives/README.md index 82b3c22..43e4839 100644 --- a/docs/primitives/README.md +++ b/docs/primitives/README.md @@ -2,13 +2,13 @@ Yield has a deliberately small API. Each primitive has one clear owner. -| Primitive | What it does | Who performs it | -|---|---|---| -| [`RunCommand`](run-command.md) | Runs a command and records its real output | `yskill` | -| [`AgentTask`](agent-task.md) | Requests model judgment with an optional JSON schema | coding agent | -| [`AskUser`](ask-user.md) | Pauses for a human answer | coding agent and user | -| [`Require`](require.md) | Prevents completion unless a claim passes | skill workflow | -| [Outcomes](outcomes.md) | Completes, blocks, or refuses with a recorded reason | skill workflow | +| Primitive | What it does | Who performs it | +| ------------------------------ | ---------------------------------------------------- | --------------------- | +| [`RunCommand`](run-command.md) | Runs a command and records its real output | `yskill` | +| [`AgentTask`](agent-task.md) | Requests model judgment with an optional JSON schema | coding agent | +| [`AskUser`](ask-user.md) | Pauses for a human answer | coding agent and user | +| [`Require`](require.md) | Prevents completion unless a claim passes | skill workflow | +| [Outcomes](outcomes.md) | Completes, blocks, or refuses with a recorded reason | skill workflow | Ordinary language features provide the rest. Use `if` for choices, `for` or `while` for bounded retries, functions for reusable flows, and your language's @@ -16,15 +16,15 @@ types for local data. ## Names in each SDK -| Meaning | TypeScript | Python | Go | Rust | -|---|---|---|---|---| -| ask a person | `ctx.askUser` | `ctx.ask_user` | `ctx.AskUser` | `ctx.ask_user` | -| ask the model | `ctx.agentTask` | `ctx.agent_task` | `ctx.AgentTask` | `ctx.agent_task` | -| run a command | `ctx.runCommand` | `ctx.run_command` | `ctx.RunCommand` | `ctx.run_command` | -| enforce a claim | `ctx.require` | `ctx.require` | `ctx.Require` | `ctx.require` | -| finish | `return value` | `return value` | `ctx.Complete` | `Ok(value)` | -| cannot continue | `ctx.blocked` | `ctx.blocked` | `ctx.Blocked` | `Err(ctx.blocked(...))` | -| decline to continue | `ctx.refused` | `ctx.refused` | `ctx.Refused` | `Err(ctx.refused(...))` | +| Meaning | TypeScript | Python | Go | Rust | +| ------------------- | ---------------- | ----------------- | ---------------- | ----------------------- | +| ask a person | `ctx.askUser` | `ctx.ask_user` | `ctx.AskUser` | `ctx.ask_user` | +| ask the model | `ctx.agentTask` | `ctx.agent_task` | `ctx.AgentTask` | `ctx.agent_task` | +| run a command | `ctx.runCommand` | `ctx.run_command` | `ctx.RunCommand` | `ctx.run_command` | +| enforce a claim | `ctx.require` | `ctx.require` | `ctx.Require` | `ctx.require` | +| finish | `return value` | `return value` | `ctx.Complete` | `Ok(value)` | +| cannot continue | `ctx.blocked` | `ctx.blocked` | `ctx.Blocked` | `Err(ctx.blocked(...))` | +| decline to continue | `ctx.refused` | `ctx.refused` | `ctx.Refused` | `Err(ctx.refused(...))` | All four SDKs emit the same `yield.v1` protocol. Choose the language that best fits the repository containing the skill. diff --git a/docs/primitives/agent-task.md b/docs/primitives/agent-task.md index 6cd82f1..5a9d44b 100644 --- a/docs/primitives/agent-task.md +++ b/docs/primitives/agent-task.md @@ -5,7 +5,7 @@ diagnosing a failure, comparing designs, extracting a policy, or proposing a fix. ```ts -type Diagnosis = { cause: string; confidence: number }; +type Diagnosis = { cause: string; confidence: number } const diagnosis = ctx.agentTask( "diagnose", @@ -19,7 +19,7 @@ const diagnosis = ctx.agentTask( confidence: { type: "number" }, }, }, -); +) ``` The arguments are: diff --git a/docs/primitives/ask-user.md b/docs/primitives/ask-user.md index a90d4f6..9bdeb84 100644 --- a/docs/primitives/ask-user.md +++ b/docs/primitives/ask-user.md @@ -7,9 +7,9 @@ an irreversible action. const answer = ctx.askUser("approve", "Publish this release?", [ { value: "yes", label: "Publish" }, { value: "no", label: "Stop" }, -]); +]) -if (answer !== "yes") ctx.refused("the user declined publication"); +if (answer !== "yes") ctx.refused("the user declined publication") ``` The coding agent asks through its normal interface. Yield records the answer diff --git a/docs/primitives/outcomes.md b/docs/primitives/outcomes.md index e4c8ca8..6dcfa27 100644 --- a/docs/primitives/outcomes.md +++ b/docs/primitives/outcomes.md @@ -7,7 +7,7 @@ Every run should end honestly. Return the useful result when the workflow has satisfied its requirements: ```ts -return { published: true, version }; +return { published: true, version } ``` TypeScript and Python complete by returning. Go uses `ctx.Complete(value)` and @@ -19,7 +19,7 @@ Use `Blocked` when the workflow cannot continue without new information or a real-world change: ```ts -ctx.blocked("three probes failed; new evidence is required"); +ctx.blocked("three probes failed; new evidence is required") ``` Blocked is not an error to hide. It records the frontier so another session can @@ -30,7 +30,7 @@ understand why the work stopped. Use `Refused` when the workflow deliberately declines to perform an action: ```ts -if (approval !== "yes") ctx.refused("release not approved"); +if (approval !== "yes") ctx.refused("release not approved") ``` Refused is useful for rejected approvals, unsafe requests, or policy choices. diff --git a/docs/primitives/require.md b/docs/primitives/require.md index e60fed1..60dadce 100644 --- a/docs/primitives/require.md +++ b/docs/primitives/require.md @@ -3,8 +3,8 @@ Use `Require` when the workflow must not continue unless a claim is true. ```ts -const dryRun = ctx.runCommand("dry-run", "npm run migrate -- --dry-run", 300); -ctx.require(dryRun.exit_code === 0, "the migration dry run succeeds", dryRun); +const dryRun = ctx.runCommand("dry-run", "npm run migrate -- --dry-run", 300) +ctx.require(dryRun.exit_code === 0, "the migration dry run succeeds", dryRun) ``` A failed requirement records `requirement_failed` and ends the program at that @@ -14,7 +14,7 @@ Pass the value supporting the claim as evidence. Yield stores its digest with the requirement: ```ts -ctx.require(review.critical === 0, "no critical findings remain", review); +ctx.require(review.critical === 0, "no critical findings remain", review) ``` ## What `Require` does not do diff --git a/docs/primitives/run-command.md b/docs/primitives/run-command.md index 0435e53..5ff1a73 100644 --- a/docs/primitives/run-command.md +++ b/docs/primitives/run-command.md @@ -5,8 +5,8 @@ output. Typical uses are tests, type checks, builds, dry runs, deploy commands, and verification probes. ```ts -const test = ctx.runCommand("test", "npm test", 300); -ctx.require(test.exit_code === 0, "the tests pass", test); +const test = ctx.runCommand("test", "npm test", 300) +ctx.require(test.exit_code === 0, "the tests pass", test) ``` The arguments are: @@ -24,8 +24,8 @@ command timed out. Pass the command result to `Require` when a later completion depends on it: ```ts -const build = ctx.runCommand("build", "npm run build", 600); -ctx.require(build.exit_code === 0, "the production build succeeds", build); +const build = ctx.runCommand("build", "npm run build", 600) +ctx.require(build.exit_code === 0, "the production build succeeds", build) ``` This binds the claim to the recorded command result. A failed requirement ends diff --git a/docs/reference/sdk-parity.md b/docs/reference/sdk-parity.md index 891b495..5c83ab5 100644 --- a/docs/reference/sdk-parity.md +++ b/docs/reference/sdk-parity.md @@ -3,17 +3,17 @@ Go, TypeScript, Python, and Rust implement the same observable `yield.v1` contract. -| Language | Package | Program entry | -|---|---|---| -| TypeScript | `@operatorstack/yield` | `defineSkill((ctx) => value)` | -| Python | `yieldskill` | `define_skill(program)` | -| Go | `github.com/operatorstack/yield/sdk/yield` | `yield.Main(program)` | -| Rust | `yieldskill` | `yieldskill::define_skill(program)` | +| Language | Package | Program entry | +| ---------- | ------------------------------------------ | ----------------------------------- | +| TypeScript | `@operatorstack/yield` | `defineSkill((ctx) => value)` | +| Python | `yieldskill` | `define_skill(program)` | +| Go | `github.com/operatorstack/yield/sdk/yield` | `yield.Main(program)` | +| Rust | `yieldskill` | `yieldskill::define_skill(program)` | Skills declare their language and runner in `skill.json`, for example: ```json -{"version":1,"language":"typescript","run":["node","main.ts"]} +{ "version": 1, "language": "typescript", "run": ["node", "main.ts"] } ``` The conformance suite runs the same workflow in all four languages and compares diff --git a/docs/skill-workflows.md b/docs/skill-workflows.md index ac6f81b..270d345 100644 --- a/docs/skill-workflows.md +++ b/docs/skill-workflows.md @@ -5,12 +5,12 @@ skills with deterministic code, state, and verification. The terms have separate jobs: -| term | meaning | -|---|---| -| **skill** | one reusable capability, described for the coding agent | -| **workflow** | sequencing, branching, checks, and saved state | -| **skill workflow** | an executable composition of skills, code, commands, and human input | -| **adapter** | a generated `SKILL.md` that lets one coding agent discover the workflow | +| term | meaning | +| ------------------ | ----------------------------------------------------------------------- | +| **skill** | one reusable capability, described for the coding agent | +| **workflow** | sequencing, branching, checks, and saved state | +| **skill workflow** | an executable composition of skills, code, commands, and human input | +| **adapter** | a generated `SKILL.md` that lets one coding agent discover the workflow | ## The two slices diff --git a/docs/tutorials/approval.md b/docs/tutorials/approval.md index 3aa2f5f..6ccbe1f 100644 --- a/docs/tutorials/approval.md +++ b/docs/tutorials/approval.md @@ -6,13 +6,13 @@ An approval belongs before the command that changes the system. const approval = ctx.askUser("approve", "Publish this release?", [ { value: "yes", label: "Publish" }, { value: "no", label: "Stop" }, -]); +]) -if (approval !== "yes") ctx.refused("the user declined publication"); +if (approval !== "yes") ctx.refused("the user declined publication") -const publish = ctx.runCommand("publish", "npm run publish", 600); -ctx.require(publish.exit_code === 0, "the publish command succeeds", publish); -return { published: true }; +const publish = ctx.runCommand("publish", "npm run publish", 600) +ctx.require(publish.exit_code === 0, "the publish command succeeds", publish) +return { published: true } ``` Yield does not provide publishing logic. `npm run publish` is your command and diff --git a/docs/tutorials/code-review.md b/docs/tutorials/code-review.md index 46125e8..a04526f 100644 --- a/docs/tutorials/code-review.md +++ b/docs/tutorials/code-review.md @@ -8,18 +8,18 @@ Many review skills mix two different jobs in prose: Yield gives each job a clear owner. ```ts -const check = ctx.runCommand("check", "npm run typecheck", 300); -ctx.require(check.exit_code === 0, "typecheck passes", check); +const check = ctx.runCommand("check", "npm run typecheck", 300) +ctx.require(check.exit_code === 0, "typecheck passes", check) const review = ctx.agentTask( "review", "Review the branch for correctness, security, and data-loss risks.", undefined, reviewSchema, -); +) -ctx.require(review.critical === 0, "no critical findings remain", review); -return review; +ctx.require(review.critical === 0, "no critical findings remain", review) +return review ``` ## Why this split helps diff --git a/evals/conversion/README.md b/evals/conversion/README.md index 6490491..6595d35 100644 --- a/evals/conversion/README.md +++ b/evals/conversion/README.md @@ -21,12 +21,12 @@ the reason for an exclusion. 4. Give a reason for each excluded clause. 5. Pass the source and projection into flow extraction, writing, and repair. -| Source clause | Disposition | Required destination | -|---|---|---| -| Run tests and stop on failure. | `control` | Code | -| Prefer changed-code evidence. | `guidance` | `SKILL.md` or `agent_task` | -| Ask for approval and explain why. | `both` | Code and model-facing guidance | -| Yarn is only release history. | `excluded` | No destination; give a reason | +| Source clause | Disposition | Required destination | +| --------------------------------- | ----------- | ------------------------------ | +| Run tests and stop on failure. | `control` | Code | +| Prefer changed-code evidence. | `guidance` | `SKILL.md` or `agent_task` | +| Ask for approval and explain why. | `both` | Code and model-facing guidance | +| Yarn is only release history. | `excluded` | No destination; give a reason | Run the paid evaluation: diff --git a/evals/conversion/judge-schema.json b/evals/conversion/judge-schema.json index 7daeed0..f43d93d 100644 --- a/evals/conversion/judge-schema.json +++ b/evals/conversion/judge-schema.json @@ -8,8 +8,8 @@ "required": ["verdict", "reason"], "additionalProperties": false, "properties": { - "verdict": {"enum": ["accept", "reject"]}, - "reason": {"type": "string", "minLength": 1} + "verdict": { "enum": ["accept", "reject"] }, + "reason": { "type": "string", "minLength": 1 } } }, "negative_control": { @@ -17,8 +17,8 @@ "required": ["verdict", "reason"], "additionalProperties": false, "properties": { - "verdict": {"enum": ["accept", "reject"]}, - "reason": {"type": "string", "minLength": 1} + "verdict": { "enum": ["accept", "reject"] }, + "reason": { "type": "string", "minLength": 1 } } }, "clause_findings": { @@ -29,24 +29,30 @@ "required": ["source_clause", "disposition", "preserved", "reachable", "finding"], "additionalProperties": false, "properties": { - "source_clause": {"type": "string", "minLength": 1}, - "disposition": {"enum": ["control", "guidance", "both", "excluded"]}, - "preserved": {"type": "boolean"}, - "reachable": {"type": "boolean"}, - "finding": {"type": "string", "minLength": 1} + "source_clause": { "type": "string", "minLength": 1 }, + "disposition": { "enum": ["control", "guidance", "both", "excluded"] }, + "preserved": { "type": "boolean" }, + "reachable": { "type": "boolean" }, + "finding": { "type": "string", "minLength": 1 } } } }, "defect_detection": { "type": "object", - "required": ["missing", "contradictory", "incorrectly_duplicated", "excluded_without_reason", "unreachable"], + "required": [ + "missing", + "contradictory", + "incorrectly_duplicated", + "excluded_without_reason", + "unreachable" + ], "additionalProperties": false, "properties": { - "missing": {"type": "boolean"}, - "contradictory": {"type": "boolean"}, - "incorrectly_duplicated": {"type": "boolean"}, - "excluded_without_reason": {"type": "boolean"}, - "unreachable": {"type": "boolean"} + "missing": { "type": "boolean" }, + "contradictory": { "type": "boolean" }, + "incorrectly_duplicated": { "type": "boolean" }, + "excluded_without_reason": { "type": "boolean" }, + "unreachable": { "type": "boolean" } } } } diff --git a/evals/conversion/scripts/conversion.test.mjs b/evals/conversion/scripts/conversion.test.mjs index 5332157..9dc4d82 100644 --- a/evals/conversion/scripts/conversion.test.mjs +++ b/evals/conversion/scripts/conversion.test.mjs @@ -13,7 +13,15 @@ test("router selects every inventoried path", () => { }) test("router skips explicit non-semantic paths", () => { - for (const path of ["README.md", "docs/quickstart.md", "cmd/yskill/bootstrap.go", "cmd/yskill/scaffold.go", "sdk/typescript/src/index.ts", ".agents/skills/example/SKILL.md", "evals/conversion/README.md"]) { + for (const path of [ + "README.md", + "docs/quickstart.md", + "cmd/yskill/bootstrap.go", + "cmd/yskill/scaffold.go", + "sdk/typescript/src/index.ts", + ".agents/skills/example/SKILL.md", + "evals/conversion/README.md", + ]) { assert.equal(isSemanticPath(path), false, path) } }) @@ -26,11 +34,21 @@ async function validReceipt() { test("validator rejects stale, malformed, failing, and rubber-stamping receipts", async () => { const mutations = [ - (r) => { r.source_hash = "stale" }, - (r) => { delete r.model }, - (r) => { r.status = "failed" }, - (r) => { r.negative_control_verdict = "accept" }, - (r) => { r.defect_detection.unreachable = false }, + (r) => { + r.source_hash = "stale" + }, + (r) => { + delete r.model + }, + (r) => { + r.status = "failed" + }, + (r) => { + r.negative_control_verdict = "accept" + }, + (r) => { + r.defect_detection.unreachable = false + }, ] for (const mutate of mutations) { const receipt = await validReceipt() diff --git a/evals/conversion/scripts/receipt.mjs b/evals/conversion/scripts/receipt.mjs index be3f32b..12ab1ca 100644 --- a/evals/conversion/scripts/receipt.mjs +++ b/evals/conversion/scripts/receipt.mjs @@ -10,24 +10,56 @@ export async function validateReceiptFile(path = receiptPath) { export async function validateReceipt(receipt) { if (receipt === undefined) return validateReceiptFile() - const fail = (message) => { throw new Error(message) } + const fail = (message) => { + throw new Error(message) + } if (receipt.schema_version !== 1) fail("unsupported conversion receipt schema") - if (receipt.methodology_version !== "semantic-disposition-v1") fail("unsupported conversion evaluation method") - if (receipt.source_hash !== await sourceHash()) fail("conversion receipt has a stale source hash") + if (receipt.methodology_version !== "semantic-disposition-v1") + fail("unsupported conversion evaluation method") + if (receipt.source_hash !== (await sourceHash())) + fail("conversion receipt has a stale source hash") if (receipt.status !== "passed") fail("conversion receipt is not passing") - if (receipt.model?.product !== "Codex CLI" || receipt.model?.name !== "gpt-5.6-sol" || receipt.model?.reasoning !== "medium") fail("conversion receipt used the wrong model") + if ( + receipt.model?.product !== "Codex CLI" || + receipt.model?.name !== "gpt-5.6-sol" || + receipt.model?.reasoning !== "medium" + ) + fail("conversion receipt used the wrong model") if (receipt.sessions !== 2) fail("conversion evaluation must use exactly two fresh sessions") - for (const key of ["input_tokens", "cached_input_tokens", "output_tokens", "reasoning_output_tokens"]) { - if (!Number.isInteger(receipt.token_usage?.[key]) || receipt.token_usage[key] < 0) fail(`invalid token usage: ${key}`) + for (const key of [ + "input_tokens", + "cached_input_tokens", + "output_tokens", + "reasoning_output_tokens", + ]) { + if (!Number.isInteger(receipt.token_usage?.[key]) || receipt.token_usage[key] < 0) + fail(`invalid token usage: ${key}`) } const counts = receipt.clause_counts ?? {} - if (counts.total !== 4 || counts.control !== 1 || counts.guidance !== 1 || counts.both !== 1 || counts.excluded !== 1) fail("conversion receipt does not cover each disposition once") + if ( + counts.total !== 4 || + counts.control !== 1 || + counts.guidance !== 1 || + counts.both !== 1 || + counts.excluded !== 1 + ) + fail("conversion receipt does not cover each disposition once") if (receipt.candidate_verdict !== "accept") fail("generated candidate was not accepted") if (receipt.negative_control_verdict !== "reject") fail("negative control was not rejected") const defects = receipt.defect_detection ?? {} - for (const key of ["missing", "contradictory", "incorrectly_duplicated", "excluded_without_reason", "unreachable"]) { + for (const key of [ + "missing", + "contradictory", + "incorrectly_duplicated", + "excluded_without_reason", + "unreachable", + ]) { if (defects[key] !== true) fail(`judge did not detect ${key}`) } - if (receipt.claim_boundary !== "Advisory evidence for this four-clause fixture. The contract is stable; model projections can differ.") fail("conversion receipt has the wrong advisory claim boundary") + if ( + receipt.claim_boundary !== + "Advisory evidence for this four-clause fixture. The contract is stable; model projections can differ." + ) + fail("conversion receipt has the wrong advisory claim boundary") return receipt } diff --git a/evals/conversion/scripts/run.mjs b/evals/conversion/scripts/run.mjs index 0e9ff27..e77ac82 100644 --- a/evals/conversion/scripts/run.mjs +++ b/evals/conversion/scripts/run.mjs @@ -1,6 +1,16 @@ import { createHash } from "node:crypto" import { execFileSync, spawnSync } from "node:child_process" -import { chmod, cp, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises" +import { + chmod, + cp, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rm, + writeFile, +} from "node:fs/promises" import { homedir, tmpdir } from "node:os" import { dirname, join, relative } from "node:path" import { conversionRoot, evalRoot, sourceHash, yieldRoot } from "./surface.mjs" @@ -26,7 +36,12 @@ async function prepareAuthHome(parent) { } function parseUsage(stdout) { - let usage = { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0, reasoning_output_tokens: 0 } + let usage = { + input_tokens: 0, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + } for (const line of stdout.split("\n")) { if (!line.startsWith("{")) continue try { @@ -39,13 +54,41 @@ function parseUsage(stdout) { async function runCodex({ repo, authHome, prompt, evidenceDir, outputSchema, outputFile }) { const args = [ - "exec", "--ephemeral", "--ignore-user-config", "--ignore-rules", - "--disable", "plugins", "--disable", "remote_plugin", "--disable", "apps", - "--disable", "memories", "--disable", "goals", "--disable", "multi_agent", - "--disable", "browser_use", "--disable", "computer_use", "--disable", "image_generation", - "--disable", "skill_search", "--disable", "workspace_dependencies", - "--json", "--sandbox", "danger-full-access", "-C", repo, - "--model", model, "-c", `model_reasoning_effort=\"${reasoning}\"`, + "exec", + "--ephemeral", + "--ignore-user-config", + "--ignore-rules", + "--disable", + "plugins", + "--disable", + "remote_plugin", + "--disable", + "apps", + "--disable", + "memories", + "--disable", + "goals", + "--disable", + "multi_agent", + "--disable", + "browser_use", + "--disable", + "computer_use", + "--disable", + "image_generation", + "--disable", + "skill_search", + "--disable", + "workspace_dependencies", + "--json", + "--sandbox", + "danger-full-access", + "-C", + repo, + "--model", + model, + "-c", + `model_reasoning_effort=\"${reasoning}\"`, ] if (outputSchema) args.push("--output-schema", outputSchema) args.push("--output-last-message", outputFile, prompt) @@ -59,7 +102,8 @@ async function runCodex({ repo, authHome, prompt, evidenceDir, outputSchema, out await mkdir(evidenceDir, { recursive: true }) await writeFile(join(evidenceDir, "transcript.jsonl"), execution.stdout ?? "") await writeFile(join(evidenceDir, "stderr.log"), execution.stderr ?? "") - if (execution.status !== 0) throw new Error(`Codex exited ${execution.status}; see ${evidenceDir}`) + if (execution.status !== 0) + throw new Error(`Codex exited ${execution.status}; see ${evidenceDir}`) return parseUsage(execution.stdout ?? "") } @@ -68,19 +112,29 @@ async function prepareRepository(session, yskill) { await mkdir(unresolved) const repo = await realpath(unresolved) await mkdir(join(repo, "skills/source-release"), { recursive: true }) - await cp(join(conversionRoot, "fixtures/source-skill/SKILL.md"), join(repo, "skills/source-release/SKILL.md")) + await cp( + join(conversionRoot, "fixtures/source-skill/SKILL.md"), + join(repo, "skills/source-release/SKILL.md"), + ) await writeFile(join(repo, "go.mod"), "module example.com/conversion-eval\n\ngo 1.26.5\n") command("git", ["init", "-b", "main"], repo) command("git", ["config", "user.email", "eval@operatorstack.systems"], repo) command("git", ["config", "user.name", "Yield Eval"], repo) - command(yskill, ["bootstrap", "--root", repo, "--language", "go", "--agent", "codex", "--yes"], repo) + command( + yskill, + ["bootstrap", "--root", repo, "--language", "go", "--agent", "codex", "--yes"], + repo, + ) command("git", ["add", "."], repo) command("git", ["commit", "-m", "conversion evaluation fixture"], repo) return repo } async function readEvents(path) { - return (await readFile(path, "utf8")).split("\n").filter(Boolean).map((line) => JSON.parse(line)) + return (await readFile(path, "utf8")) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)) } async function builderEvidence(repo) { @@ -90,21 +144,37 @@ async function builderEvidence(repo) { for (const name of logs.sort()) { const path = join(directory, name) const events = await readEvents(path) - const semantic = events.some((event) => event.type === "operation.completed" && event.data?.request_id === "project-semantics") + const semantic = events.some( + (event) => + event.type === "operation.completed" && event.data?.request_id === "project-semantics", + ) const complete = events.some((event) => event.type === "run.completed") if (semantic && (complete || !selected)) selected = { path, events } } if (!selected) throw new Error("builder produced no semantic-conversion run log") const { path, events } = selected - const terminal = events.findLast((event) => event.type === "run.completed" || event.type === "run.blocked" || event.type === "run.refused") - if (terminal?.type !== "run.completed") throw new Error(`builder terminal was ${terminal?.type ?? "missing"}`) - const projected = events.find((event) => event.type === "operation.completed" && event.data?.request_id === "project-semantics") + const terminal = events.findLast( + (event) => + event.type === "run.completed" || + event.type === "run.blocked" || + event.type === "run.refused", + ) + if (terminal?.type !== "run.completed") + throw new Error(`builder terminal was ${terminal?.type ?? "missing"}`) + const projected = events.find( + (event) => + event.type === "operation.completed" && event.data?.request_id === "project-semantics", + ) if (!projected?.data?.result) throw new Error("builder run log has no semantic projection") return { path, projection: projected.data.result } } function addUsage(left, right) { - return Object.fromEntries(["input_tokens", "cached_input_tokens", "output_tokens", "reasoning_output_tokens"].map((key) => [key, (left[key] ?? 0) + (right[key] ?? 0)])) + return Object.fromEntries( + ["input_tokens", "cached_input_tokens", "output_tokens", "reasoning_output_tokens"].map( + (key) => [key, (left[key] ?? 0) + (right[key] ?? 0)], + ), + ) } async function main() { @@ -123,14 +193,25 @@ async function main() { authHome, evidenceDir: join(evidenceRoot, "candidate"), outputFile: candidateOutput, - prompt: "Read .agents/skills/yield-workflow-builder/SKILL.md and use the real builder. Convert skills/source-release into a new Go skill workflow at skills/converted-release. Drive every Yield operation to a terminal result. Do not imitate the workflow or bypass yskill. Use the current request as the specification.", + prompt: + "Read .agents/skills/yield-workflow-builder/SKILL.md and use the real builder. Convert skills/source-release into a new Go skill workflow at skills/converted-release. Drive every Yield operation to a terminal result. Do not imitate the workflow or bypass yskill. Use the current request as the specification.", }) const evidence = await builderEvidence(repo) await cp(evidence.path, join(evidenceRoot, "candidate/builder-run.jsonl")) await mkdir(join(repo, ".eval/negative-control"), { recursive: true }) - await cp(join(conversionRoot, "fixtures/negative-control"), join(repo, ".eval/negative-control"), { recursive: true }) - await cp(join(conversionRoot, "fixtures/fault-probes.json"), join(repo, ".eval/fault-probes.json")) - await writeFile(join(repo, ".eval/projection.json"), JSON.stringify(evidence.projection, null, 2) + "\n") + await cp( + join(conversionRoot, "fixtures/negative-control"), + join(repo, ".eval/negative-control"), + { recursive: true }, + ) + await cp( + join(conversionRoot, "fixtures/fault-probes.json"), + join(repo, ".eval/fault-probes.json"), + ) + await writeFile( + join(repo, ".eval/projection.json"), + JSON.stringify(evidence.projection, null, 2) + "\n", + ) const judgeOutput = join(repo, ".eval/judge.json") const judgeUsage = await runCodex({ repo, @@ -138,15 +219,25 @@ async function main() { evidenceDir: join(evidenceRoot, "judge"), outputSchema: join(conversionRoot, "judge-schema.json"), outputFile: judgeOutput, - prompt: "Act as an independent semantic-disposition judge. Read skills/source-release/SKILL.md, .eval/projection.json, every file in skills/converted-release, every file in .eval/negative-control, and .eval/fault-probes.json. Accept the generated candidate only if every source clause has exactly one valid disposition, each required destination exists and remains reachable by a coding agent, control is enforced in code, useful guidance remains model-facing, both reaches both, and exclusions have no destination plus a reason. Reject the static negative control because it deliberately drops guidance. Mark each defect probe true only when you recognize that it must be rejected. Return only schema-valid JSON.", + prompt: + "Act as an independent semantic-disposition judge. Read skills/source-release/SKILL.md, .eval/projection.json, every file in skills/converted-release, every file in .eval/negative-control, and .eval/fault-probes.json. Accept the generated candidate only if every source clause has exactly one valid disposition, each required destination exists and remains reachable by a coding agent, control is enforced in code, useful guidance remains model-facing, both reaches both, and exclusions have no destination plus a reason. Reject the static negative control because it deliberately drops guidance. Mark each defect probe true only when you recognize that it must be rejected. Return only schema-valid JSON.", }) const judge = JSON.parse(await readFile(judgeOutput, "utf8")) const clauses = evidence.projection.clauses ?? [] - const counts = Object.fromEntries(["control", "guidance", "both", "excluded"].map((kind) => [kind, clauses.filter((clause) => clause.disposition === kind).length])) + const counts = Object.fromEntries( + ["control", "guidance", "both", "excluded"].map((kind) => [ + kind, + clauses.filter((clause) => clause.disposition === kind).length, + ]), + ) const findings = judge.clause_findings ?? [] - const passed = judge.candidate?.verdict === "accept" && judge.negative_control?.verdict === "reject" && - clauses.length === 4 && Object.values(counts).every((count) => count === 1) && - findings.length === 4 && findings.every((finding) => finding.preserved === true && finding.reachable === true) && + const passed = + judge.candidate?.verdict === "accept" && + judge.negative_control?.verdict === "reject" && + clauses.length === 4 && + Object.values(counts).every((count) => count === 1) && + findings.length === 4 && + findings.every((finding) => finding.preserved === true && finding.reachable === true) && Object.values(judge.defect_detection ?? {}).every((value) => value === true) const source = await readFile(join(conversionRoot, "fixtures/source-skill/SKILL.md")) const receipt = { @@ -156,17 +247,34 @@ async function main() { source_hash: await sourceHash(), fixture_source_hash: createHash("sha256").update(source).digest("hex"), status: passed ? "passed" : "failed", - model: { product: "Codex CLI", cli_version: command("codex", ["--version"]), name: model, reasoning }, + model: { + product: "Codex CLI", + cli_version: command("codex", ["--version"]), + name: model, + reasoning, + }, sessions: 2, token_usage: addUsage(candidateUsage, judgeUsage), clause_counts: { total: clauses.length, ...counts }, candidate_verdict: judge.candidate?.verdict, negative_control_verdict: judge.negative_control?.verdict, defect_detection: judge.defect_detection, - claim_boundary: "Advisory evidence for this four-clause fixture. The contract is stable; model projections can differ.", + claim_boundary: + "Advisory evidence for this four-clause fixture. The contract is stable; model projections can differ.", } - if (process.argv.includes("--write")) await writeFile(resultPath, JSON.stringify(receipt, null, 2) + "\n") - console.log(JSON.stringify({ status: receipt.status, clauses: receipt.clause_counts, evidence: relative(evalRoot, evidenceRoot) }, null, 2)) + if (process.argv.includes("--write")) + await writeFile(resultPath, JSON.stringify(receipt, null, 2) + "\n") + console.log( + JSON.stringify( + { + status: receipt.status, + clauses: receipt.clause_counts, + evidence: relative(evalRoot, evidenceRoot), + }, + null, + 2, + ), + ) if (!passed) process.exitCode = 1 } finally { await rm(session, { recursive: true, force: true }) diff --git a/evals/conversion/scripts/surface.mjs b/evals/conversion/scripts/surface.mjs index 610b779..424c0c7 100644 --- a/evals/conversion/scripts/surface.mjs +++ b/evals/conversion/scripts/surface.mjs @@ -19,7 +19,12 @@ export const receiptSurface = "evals/results/latest-conversion.json" export function isSemanticPath(path) { const normalized = path.replaceAll("\\", "/") - return normalized === receiptSurface || semanticSurface.some((entry) => entry.endsWith("/") ? normalized.startsWith(entry) : normalized === entry) + return ( + normalized === receiptSurface || + semanticSurface.some((entry) => + entry.endsWith("/") ? normalized.startsWith(entry) : normalized === entry, + ) + ) } async function filesUnder(path) { @@ -27,7 +32,7 @@ async function filesUnder(path) { const files = [] for (const entry of await readdir(path, { withFileTypes: true })) { const child = join(path, entry.name) - if (entry.isDirectory()) files.push(...await filesUnder(child)) + if (entry.isDirectory()) files.push(...(await filesUnder(child))) else files.push(child) } return files @@ -35,7 +40,7 @@ async function filesUnder(path) { export async function semanticFiles() { const files = [] - for (const entry of semanticSurface) files.push(...await filesUnder(join(yieldRoot, entry))) + for (const entry of semanticSurface) files.push(...(await filesUnder(join(yieldRoot, entry)))) return [...new Set(files)].sort() } @@ -53,6 +58,11 @@ export async function sourceHash() { export function changedPaths(base, head) { if (!base || !head) return null const effectiveBase = /^0+$/.test(base) ? `${head}^` : base - return execFileSync("git", ["diff", "--name-only", effectiveBase, head], { cwd: yieldRoot, encoding: "utf8" }) - .split("\n").map((path) => path.trim()).filter(Boolean) + return execFileSync("git", ["diff", "--name-only", effectiveBase, head], { + cwd: yieldRoot, + encoding: "utf8", + }) + .split("\n") + .map((path) => path.trim()) + .filter(Boolean) } diff --git a/evals/results/latest-conversion.json b/evals/results/latest-conversion.json index 8848b31..c957617 100644 --- a/evals/results/latest-conversion.json +++ b/evals/results/latest-conversion.json @@ -1,8 +1,8 @@ { "schema_version": 1, "methodology_version": "semantic-disposition-v1", - "generated_at": "2026-08-08T10:48:36.169Z", - "source_hash": "0f911c060c888838408aa0e5c4a229cc6b6ee32631ac5ed70bdc6a2ecc097405", + "generated_at": "2026-08-08T11:09:09.457Z", + "source_hash": "5698f566b14881eec878509254d22cf558b1ce60e1109fa3c5b67cdb5a6d4752", "fixture_source_hash": "9ab03fffe6716da8298b461f79c9eebae7c7bb01151328686ec51ed9c0b77fe5", "status": "passed", "model": { @@ -13,10 +13,10 @@ }, "sessions": 2, "token_usage": { - "input_tokens": 613869, - "cached_input_tokens": 555676, - "output_tokens": 8875, - "reasoning_output_tokens": 2776 + "input_tokens": 704111, + "cached_input_tokens": 637615, + "output_tokens": 10985, + "reasoning_output_tokens": 3084 }, "clause_counts": { "total": 4, diff --git a/evals/results/latest.json b/evals/results/latest.json index 0192246..e080967 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-08T10:49:07.813Z", - "source_digest": "e82087e147677abbe53882e06888ecbdd36ade96c25412d9b2e815ff8428be61", + "generated_at": "2026-08-08T11:05:49.678Z", + "source_digest": "6b370013cbe8327035d4b34df6116786e89fd6fdb64d1de7436261ab801f8bc3", "status": "passed", "workflow_conformance": { "passed": 40, diff --git a/evals/scripts/run.mjs b/evals/scripts/run.mjs index 636030b..f27abf5 100644 --- a/evals/scripts/run.mjs +++ b/evals/scripts/run.mjs @@ -10,20 +10,75 @@ const yieldRoot = resolve(evalRoot, "..") const libraryRoot = join(yieldRoot, "examples/library") const languages = ["typescript", "python", "go", "rust"] const runtimeCases = [ - ["resume-complete", "./internal/engine", "TestEndToEndRunResumeComplete", "a recorded response advances the run to completion"], - ["response-lock", "./internal/engine", "TestConcurrentIdenticalResumeCommitsOnce", "concurrent identical responses create one completion event"], - ["response-recovery", "./internal/engine", "TestRespondRecoveryRejectsDifferentCommittedContent", "an exact committed response recovers and different content is refused"], - ["ask-user-options", "./internal/conformance", "TestGuardRefusals", "every SDK rejects an answer outside the declared options"], - ["deterministic-replay", "./internal/engine", "TestReplayIsDeterministic", "the saved log returns to the same next step"], - ["replay-divergence", "./internal/engine", "TestReplayDivergenceFailsLoudly", "changed behavior stops replay instead of reusing the wrong result"], - ["requirement-block", "./internal/engine", "TestFailedRequirementBlocksRun", "a failed rule ends the run as blocked"], - ["source-change", "./internal/engine", "TestDigestMismatchRefusedThenMigrates", "changed source is refused until the user accepts the change"], + [ + "resume-complete", + "./internal/engine", + "TestEndToEndRunResumeComplete", + "a recorded response advances the run to completion", + ], + [ + "response-lock", + "./internal/engine", + "TestConcurrentIdenticalResumeCommitsOnce", + "concurrent identical responses create one completion event", + ], + [ + "response-recovery", + "./internal/engine", + "TestRespondRecoveryRejectsDifferentCommittedContent", + "an exact committed response recovers and different content is refused", + ], + [ + "ask-user-options", + "./internal/conformance", + "TestGuardRefusals", + "every SDK rejects an answer outside the declared options", + ], + [ + "deterministic-replay", + "./internal/engine", + "TestReplayIsDeterministic", + "the saved log returns to the same next step", + ], + [ + "replay-divergence", + "./internal/engine", + "TestReplayDivergenceFailsLoudly", + "changed behavior stops replay instead of reusing the wrong result", + ], + [ + "requirement-block", + "./internal/engine", + "TestFailedRequirementBlocksRun", + "a failed rule ends the run as blocked", + ], + [ + "source-change", + "./internal/engine", + "TestDigestMismatchRefusedThenMigrates", + "changed source is refused until the user accepts the change", + ], ] const excludedDirectories = new Set([ - ".git", ".yield", "node_modules", "runs", "raw", "artifacts", - "target", "build", "dist", "__pycache__", + ".git", + ".yield", + "node_modules", + "runs", + "raw", + "artifacts", + "target", + "build", + "dist", + "__pycache__", ]) -const digestRoots = ["cmd/yskill", "internal", "sdk", "examples/library", "evals/scripts", "evals/package.json"] +const digestRoots = [ + "cmd/yskill", + "internal", + "sdk", + "examples/library", + "evals/scripts", + "evals/package.json", +] function execute(command, args, cwd = yieldRoot) { const result = spawnSync(command, args, { cwd, encoding: "utf8", env: process.env }) @@ -40,14 +95,14 @@ async function filesUnder(path) { const files = [] for (const entry of await readdir(path, { withFileTypes: true })) { if (entry.isDirectory() && excludedDirectories.has(entry.name)) continue - files.push(...await filesUnder(join(path, entry.name))) + files.push(...(await filesUnder(join(path, entry.name)))) } return files } async function sourceDigest() { const files = [] - for (const root of digestRoots) files.push(...await filesUnder(join(yieldRoot, root))) + for (const root of digestRoots) files.push(...(await filesUnder(join(yieldRoot, root)))) files.sort() const hash = createHash("sha256") for (const path of files) { @@ -67,8 +122,14 @@ async function workflowCases(yskill) { for (const pattern of catalog) { const skill = join(languageRoot, pattern.slug) const output = execute(yskill, ["test", skill]) - if (!/reached completed$/.test(output)) throw new Error(`${language}/${pattern.slug}: missing completed result`) - cases.push({ id: `${language}/${pattern.slug}`, language, pattern: pattern.slug, status: "passed" }) + if (!/reached completed$/.test(output)) + throw new Error(`${language}/${pattern.slug}: missing completed result`) + cases.push({ + id: `${language}/${pattern.slug}`, + language, + pattern: pattern.slug, + status: "passed", + }) await rm(join(skill, ".yield"), { recursive: true, force: true }) } } @@ -134,10 +195,15 @@ if (process.argv.includes("--write")) { if (published[field] !== result[field]) throw new Error(`published ${field} is stale`) } for (const field of ["workflow_conformance", "runtime_invariants", "claim_boundary"]) { - if (JSON.stringify(published[field]) !== JSON.stringify(result[field])) throw new Error(`published ${field} is stale`) + if (JSON.stringify(published[field]) !== JSON.stringify(result[field])) + throw new Error(`published ${field} is stale`) } - console.log(`passed ${result.workflow_conformance.passed}/${result.workflow_conformance.total} workflow tests`) - console.log(`passed ${result.runtime_invariants.passed}/${result.runtime_invariants.total} runtime checks`) + console.log( + `passed ${result.workflow_conformance.passed}/${result.workflow_conformance.total} workflow tests`, + ) + console.log( + `passed ${result.runtime_invariants.passed}/${result.runtime_invariants.total} runtime checks`, + ) } else { console.log(JSON.stringify(result, null, 2)) } diff --git a/evals/scripts/validate.mjs b/evals/scripts/validate.mjs index 4a951bf..60548fe 100644 --- a/evals/scripts/validate.mjs +++ b/evals/scripts/validate.mjs @@ -4,7 +4,9 @@ import { fileURLToPath } from "node:url" const root = resolve(dirname(fileURLToPath(import.meta.url)), "..") const result = JSON.parse(await readFile(join(root, "results/latest.json"), "utf8")) -const fail = (message) => { throw new Error(message) } +const fail = (message) => { + throw new Error(message) +} if (result.schema_version !== 2) fail("unsupported result schema") if (result.methodology_version !== "1.1") fail("unsupported methodology") diff --git a/examples/convert-skill/fixtures/responses.json b/examples/convert-skill/fixtures/responses.json index e65e1f6..2cad083 100644 --- a/examples/convert-skill/fixtures/responses.json +++ b/examples/convert-skill/fixtures/responses.json @@ -18,12 +18,36 @@ "extract-flow": { "summary": "Bounded incident investigation: collect evidence, form at least three hypotheses, probe cheapest-first, conclude with a causal chain or block honestly.", "steps": [ - { "id": "collect-evidence", "kind": "agent_task", "description": "Gather observable evidence for the failure." }, - { "id": "form-hypotheses", "kind": "agent_task", "description": "At least three hypotheses, cheapest-to-disprove first." }, - { "id": "probe", "kind": "run_command", "description": "Run each hypothesis's disprove command." }, - { "id": "assess", "kind": "agent_task", "description": "Judge refutation; surviving hypothesis needs a causal chain." }, - { "id": "bound", "kind": "branch", "description": "At most three failed attempts, then block." }, - { "id": "causal-chain", "kind": "require", "description": "Completion requires a stated causal chain." } + { + "id": "collect-evidence", + "kind": "agent_task", + "description": "Gather observable evidence for the failure." + }, + { + "id": "form-hypotheses", + "kind": "agent_task", + "description": "At least three hypotheses, cheapest-to-disprove first." + }, + { + "id": "probe", + "kind": "run_command", + "description": "Run each hypothesis's disprove command." + }, + { + "id": "assess", + "kind": "agent_task", + "description": "Judge refutation; surviving hypothesis needs a causal chain." + }, + { + "id": "bound", + "kind": "branch", + "description": "At most three failed attempts, then block." + }, + { + "id": "causal-chain", + "kind": "require", + "description": "Completion requires a stated causal chain." + } ] }, "pick-language": { "value": "python" }, diff --git a/examples/data-migration/skill.json b/examples/data-migration/skill.json index fb837d0..455b6ba 100644 --- a/examples/data-migration/skill.json +++ b/examples/data-migration/skill.json @@ -1,9 +1,5 @@ { "version": 1, "language": "rust", - "run": [ - "cargo", - "run", - "--quiet" - ] + "run": ["cargo", "run", "--quiet"] } diff --git a/examples/env-doctor/main.py b/examples/env-doctor/main.py index b6361db..8e3311a 100644 --- a/examples/env-doctor/main.py +++ b/examples/env-doctor/main.py @@ -4,14 +4,18 @@ import os import sys -sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "sdk", "python")) +sys.path.insert( + 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "sdk", "python") +) from yieldskill import define_skill # noqa: E402 # README_EXAMPLE_START def program(ctx): - probe = ctx.run_command("probe-python", "python3 --version || python --version", timeout_seconds=60) + probe = ctx.run_command( + "probe-python", "python3 --version || python --version", timeout_seconds=60 + ) diagnosis = ctx.agent_task( "diagnose", @@ -36,7 +40,9 @@ def program(ctx): ) if answer != "done": ctx.blocked("the environment fix was not applied") - recheck = ctx.run_command("recheck-python", "python3 --version || python --version", timeout_seconds=60) + recheck = ctx.run_command( + "recheck-python", "python3 --version || python --version", timeout_seconds=60 + ) ctx.require(recheck.exit_code == 0, "the environment probe passes after the fix", recheck) return {"healthy": True, "fixed": True} diff --git a/examples/env-doctor/skill.json b/examples/env-doctor/skill.json index 5a7ec2a..be4d4b6 100644 --- a/examples/env-doctor/skill.json +++ b/examples/env-doctor/skill.json @@ -1,8 +1,5 @@ { "version": 1, "language": "python", - "run": [ - "python3", - "main.py" - ] + "run": ["python3", "main.py"] } diff --git a/examples/library/README.md b/examples/library/README.md index dbe583d..3d08d74 100644 --- a/examples/library/README.md +++ b/examples/library/README.md @@ -4,18 +4,18 @@ Ten common skill workflows for coding agents, each implemented in TypeScript, Py Go, and Rust. Choose the language already used by your repository; the workflow and fixture are otherwise the same. -| Skill workflow | What the code keeps in order | -|---|---| -| review-branch | checks -> review -> zero-critical gate | -| investigate-failure | evidence -> diagnosis -> supported cause | -| qa-web-change | build -> changed-route QA -> no blockers | -| release-package | tests -> review -> approval -> publish -> verify | -| triage-issue | read -> classify -> one next action | -| repair-ci | failed log -> supported repair -> rerun | -| upgrade-dependency | baseline -> compatibility review -> approval -> update -> tests | -| migrate-database | dry-run -> risk review -> approval -> apply -> verify | -| audit-security | mechanical scans -> trust-boundary review -> zero-critical gate | -| publish-ios | archive -> metadata review -> approval -> upload -> processing check | +| Skill workflow | What the code keeps in order | +| ------------------- | -------------------------------------------------------------------- | +| review-branch | checks -> review -> zero-critical gate | +| investigate-failure | evidence -> diagnosis -> supported cause | +| qa-web-change | build -> changed-route QA -> no blockers | +| release-package | tests -> review -> approval -> publish -> verify | +| triage-issue | read -> classify -> one next action | +| repair-ci | failed log -> supported repair -> rerun | +| upgrade-dependency | baseline -> compatibility review -> approval -> update -> tests | +| migrate-database | dry-run -> risk review -> approval -> apply -> verify | +| audit-security | mechanical scans -> trust-boundary review -> zero-critical gate | +| publish-ios | archive -> metadata review -> approval -> upload -> processing check | The source files live under: diff --git a/examples/library/scripts/generate.mjs b/examples/library/scripts/generate.mjs index 7939ed5..dc73cb1 100644 --- a/examples/library/scripts/generate.mjs +++ b/examples/library/scripts/generate.mjs @@ -1,12 +1,12 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { execFile as execFileCallback } from "node:child_process"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; +import { mkdir, writeFile } from "node:fs/promises" +import { execFile as execFileCallback } from "node:child_process" +import { dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { promisify } from "node:util" -const libraryDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const languages = ["typescript", "python", "go", "rust"]; -const execFile = promisify(execFileCallback); +const libraryDir = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const languages = ["typescript", "python", "go", "rust"] +const execFile = promisify(execFileCallback) const patterns = [ { @@ -17,8 +17,9 @@ const patterns = [ preflightCommand: "printf 'typecheck and tests passed\\n'", preflightClaim: "the branch passes mechanical checks", decisionId: "review-diff", - instruction: "Review the branch for correctness, security, data-loss risks, and missing tests. Return pass only when no critical finding remains.", - decisionClaim: "the review has no critical findings" + instruction: + "Review the branch for correctness, security, data-loss risks, and missing tests. Return pass only when no critical finding remains.", + decisionClaim: "the review has no critical findings", }, { slug: "investigate-failure", @@ -28,8 +29,9 @@ const patterns = [ preflightCommand: "printf 'failing test captured with recent diff\\n'", preflightClaim: "the failure evidence is captured", decisionId: "diagnose-cause", - instruction: "Use the failure output and recent change to identify the most likely root cause. Return pass only when the summary states a causal chain.", - decisionClaim: "the diagnosis states a supported cause" + instruction: + "Use the failure output and recent change to identify the most likely root cause. Return pass only when the summary states a causal chain.", + decisionClaim: "the diagnosis states a supported cause", }, { slug: "qa-web-change", @@ -39,8 +41,9 @@ const patterns = [ preflightCommand: "printf 'build passed; changed routes: / and /settings\\n'", preflightClaim: "the web application builds", decisionId: "test-changed-routes", - instruction: "Test the changed routes at desktop and mobile sizes, including keyboard navigation and form errors. Return pass only when no blocking regression remains.", - decisionClaim: "the changed routes have no blocking regression" + instruction: + "Test the changed routes at desktop and mobile sizes, including keyboard navigation and form errors. Return pass only when no blocking regression remains.", + decisionClaim: "the changed routes have no blocking regression", }, { slug: "release-package", @@ -50,7 +53,8 @@ const patterns = [ preflightCommand: "printf 'package tests passed\\n'", preflightClaim: "the package tests pass", decisionId: "review-release", - instruction: "Review the pending package release for breaking changes, missing notes, and rollback risk. Return pass only when it is ready to publish.", + instruction: + "Review the pending package release for breaking changes, missing notes, and rollback risk. Return pass only when it is ready to publish.", decisionClaim: "the package is ready to publish", approvalId: "approve-publish", approvalQuestion: "Publish this package release?", @@ -59,7 +63,7 @@ const patterns = [ actionClaim: "the package publish command succeeds", verifyId: "verify-package", verifyCommand: "printf 'published package resolved from registry\\n'", - verifyClaim: "the published package resolves from the registry" + verifyClaim: "the published package resolves from the registry", }, { slug: "triage-issue", @@ -69,8 +73,9 @@ const patterns = [ preflightCommand: "printf 'issue: intermittent timeout after retry change\\n'", preflightClaim: "the issue report is available", decisionId: "classify-issue", - instruction: "Classify severity, identify missing evidence, and propose exactly one next action. Return pass only when the summary is actionable.", - decisionClaim: "the issue has one actionable next step" + instruction: + "Classify severity, identify missing evidence, and propose exactly one next action. Return pass only when the summary is actionable.", + decisionClaim: "the issue has one actionable next step", }, { slug: "repair-ci", @@ -80,14 +85,15 @@ const patterns = [ preflightCommand: "printf 'ci log: test shard 2 failed after cache restore\\n'", preflightClaim: "the failing CI evidence is captured", decisionId: "plan-ci-repair", - instruction: "Diagnose the CI failure and describe the smallest supported repair. Return pass only when the repair is tied to the observed log.", + instruction: + "Diagnose the CI failure and describe the smallest supported repair. Return pass only when the repair is tied to the observed log.", decisionClaim: "the CI repair is supported by the failure evidence", actionId: "apply-ci-repair", actionCommand: "printf 'ci repair applied\\n'", actionClaim: "the CI repair command succeeds", verifyId: "rerun-ci-check", verifyCommand: "printf 'failing CI check now passes\\n'", - verifyClaim: "the previously failing CI check passes" + verifyClaim: "the previously failing CI check passes", }, { slug: "upgrade-dependency", @@ -97,7 +103,8 @@ const patterns = [ preflightCommand: "printf 'baseline tests passed\\n'", preflightClaim: "the baseline tests pass", decisionId: "review-upgrade", - instruction: "Review the dependency upgrade for API changes, migration work, and rollback risk. Return pass only when the change is bounded.", + instruction: + "Review the dependency upgrade for API changes, migration work, and rollback risk. Return pass only when the change is bounded.", decisionClaim: "the dependency upgrade has a bounded plan", approvalId: "approve-upgrade", approvalQuestion: "Apply the reviewed dependency upgrade?", @@ -106,7 +113,7 @@ const patterns = [ actionClaim: "the dependency upgrade command succeeds", verifyId: "post-upgrade-tests", verifyCommand: "printf 'post-upgrade tests passed\\n'", - verifyClaim: "the tests pass after the dependency upgrade" + verifyClaim: "the tests pass after the dependency upgrade", }, { slug: "migrate-database", @@ -116,7 +123,8 @@ const patterns = [ preflightCommand: "printf 'dry run: add users_email_idx concurrently\\n'", preflightClaim: "the migration dry-run succeeds", decisionId: "review-migration", - instruction: "Review the migration plan for lock risk, irreversible work, and rollback. Return pass only when the plan is safe to apply.", + instruction: + "Review the migration plan for lock risk, irreversible work, and rollback. Return pass only when the plan is safe to apply.", decisionClaim: "the migration plan has acceptable risk", approvalId: "approve-migration", approvalQuestion: "Apply the reviewed database migration?", @@ -125,7 +133,7 @@ const patterns = [ actionClaim: "the migration applies cleanly", verifyId: "verify-migration", verifyCommand: "printf 'migration verification passed\\n'", - verifyClaim: "the migrated database passes verification" + verifyClaim: "the migrated database passes verification", }, { slug: "audit-security", @@ -135,8 +143,9 @@ const patterns = [ preflightCommand: "printf 'dependency and secret scans completed\\n'", preflightClaim: "the mechanical security checks complete", decisionId: "review-trust-boundaries", - instruction: "Review authentication, authorization, input handling, secrets, and trust-boundary changes. Return pass only when no critical risk remains.", - decisionClaim: "the change has no critical security finding" + instruction: + "Review authentication, authorization, input handling, secrets, and trust-boundary changes. Return pass only when no critical risk remains.", + decisionClaim: "the change has no critical security finding", }, { slug: "publish-ios", @@ -146,7 +155,8 @@ const patterns = [ preflightCommand: "printf 'iOS archive and tests passed\\n'", preflightClaim: "the iOS archive and tests pass", decisionId: "review-ios-release", - instruction: "Review the iOS release metadata, versioning, privacy notes, and rollout risk. Return pass only when the build is ready for upload.", + instruction: + "Review the iOS release metadata, versioning, privacy notes, and rollout risk. Return pass only when the build is ready for upload.", decisionClaim: "the iOS build is ready for upload", approvalId: "approve-ios-upload", approvalQuestion: "Upload this iOS build to App Store Connect?", @@ -155,9 +165,9 @@ const patterns = [ actionClaim: "the iOS upload command succeeds", verifyId: "verify-ios-processing", verifyCommand: "printf 'uploaded build entered processing\\n'", - verifyClaim: "the uploaded iOS build entered processing" - } -]; + verifyClaim: "the uploaded iOS build entered processing", + }, +] const decisionSchema = { type: "object", @@ -165,56 +175,79 @@ const decisionSchema = { properties: { status: { enum: ["pass", "needs_work"] }, critical: { type: "integer", minimum: 0 }, - summary: { type: "string", minLength: 1 } - } -}; + summary: { type: "string", minLength: 1 }, + }, +} function quoted(value) { - return JSON.stringify(value); + return JSON.stringify(value) } function indent(lines, spaces) { - const prefix = " ".repeat(spaces); - return lines.map((line) => line ? prefix + line : line); + const prefix = " ".repeat(spaces) + return lines.map((line) => (line ? prefix + line : line)) } function resultLines(pattern, language) { - if (language === "typescript") return ["return { workflow: " + quoted(pattern.slug) + ", summary: decision.summary }"]; - if (language === "python") return ["return {\"workflow\": " + quoted(pattern.slug) + ", \"summary\": decision[\"summary\"]}"]; - if (language === "go") return ["return ctx.Complete(map[string]any{\"workflow\": " + quoted(pattern.slug) + ", \"summary\": decision.Summary})"]; - return ["Ok(json!({\"workflow\": " + quoted(pattern.slug) + ", \"summary\": decision[\"summary\"]}))"]; + if (language === "typescript") + return ["return { workflow: " + quoted(pattern.slug) + ", summary: decision.summary }"] + if (language === "python") + return ['return {"workflow": ' + quoted(pattern.slug) + ', "summary": decision["summary"]}'] + if (language === "go") + return [ + 'return ctx.Complete(map[string]any{"workflow": ' + + quoted(pattern.slug) + + ', "summary": decision.Summary})', + ] + return ['Ok(json!({"workflow": ' + quoted(pattern.slug) + ', "summary": decision["summary"]}))'] } function addOptionalTypeScriptSteps(body, pattern) { if (pattern.approvalId) { body.push( "", - "const approval = ctx.askUser(" + quoted(pattern.approvalId) + ", " + quoted(pattern.approvalQuestion) + ", [", - " { value: \"continue\", label: \"Continue\" },", - " { value: \"stop\", label: \"Stop\" },", + "const approval = ctx.askUser(" + + quoted(pattern.approvalId) + + ", " + + quoted(pattern.approvalQuestion) + + ", [", + ' { value: "continue", label: "Continue" },', + ' { value: "stop", label: "Stop" },', "])", - "if (approval !== \"continue\") ctx.refused(\"the operator declined to continue\")" - ); + 'if (approval !== "continue") ctx.refused("the operator declined to continue")', + ) } if (pattern.actionId) { body.push( "", - "const action = ctx.runCommand(" + quoted(pattern.actionId) + ", " + quoted(pattern.actionCommand) + ", 600)", - "ctx.require(action.exit_code === 0, " + quoted(pattern.actionClaim) + ", action)" - ); + "const action = ctx.runCommand(" + + quoted(pattern.actionId) + + ", " + + quoted(pattern.actionCommand) + + ", 600)", + "ctx.require(action.exit_code === 0, " + quoted(pattern.actionClaim) + ", action)", + ) } if (pattern.verifyId) { body.push( "", - "const verify = ctx.runCommand(" + quoted(pattern.verifyId) + ", " + quoted(pattern.verifyCommand) + ", 300)", - "ctx.require(verify.exit_code === 0, " + quoted(pattern.verifyClaim) + ", verify)" - ); + "const verify = ctx.runCommand(" + + quoted(pattern.verifyId) + + ", " + + quoted(pattern.verifyCommand) + + ", 300)", + "ctx.require(verify.exit_code === 0, " + quoted(pattern.verifyClaim) + ", verify)", + ) } } function renderTypeScript(pattern) { const body = [ - "const preflight = ctx.runCommand(" + quoted(pattern.preflightId) + ", " + quoted(pattern.preflightCommand) + ", 300)", + "const preflight = ctx.runCommand(" + + quoted(pattern.preflightId) + + ", " + + quoted(pattern.preflightCommand) + + ", 300)", "ctx.require(preflight.exit_code === 0, " + quoted(pattern.preflightClaim) + ", preflight)", "", "const decision = ctx.agentTask(", @@ -223,22 +256,24 @@ function renderTypeScript(pattern) { " { stdout: preflight.stdout, stderr: preflight.stderr },", " decisionSchema,", ")", - "ctx.require(decision.status === \"pass\" && decision.critical === 0, " + quoted(pattern.decisionClaim) + ", decision)" - ]; - addOptionalTypeScriptSteps(body, pattern); - body.push("", ...resultLines(pattern, "typescript")); + 'ctx.require(decision.status === "pass" && decision.critical === 0, ' + + quoted(pattern.decisionClaim) + + ", decision)", + ] + addOptionalTypeScriptSteps(body, pattern) + body.push("", ...resultLines(pattern, "typescript")) return [ "// " + pattern.title + ". Replace the illustrative commands with your project commands.", - "import { defineSkill } from \"../../../../sdk/typescript/src/index.ts\";", + 'import { defineSkill } from "../../../../sdk/typescript/src/index.ts";', "", - "type Decision = { status: \"pass\" | \"needs_work\"; critical: number; summary: string };", + 'type Decision = { status: "pass" | "needs_work"; critical: number; summary: string };', "const decisionSchema = " + JSON.stringify(decisionSchema, null, 2) + ";", "", "defineSkill((ctx) => {", ...indent(body, 2), "});", - "" - ].join("\n"); + "", + ].join("\n") } function addOptionalPythonSteps(body, pattern) { @@ -248,49 +283,63 @@ function addOptionalPythonSteps(body, pattern) { "approval = ctx.ask_user(", " " + quoted(pattern.approvalId) + ",", " " + quoted(pattern.approvalQuestion) + ",", - " options=[{\"value\": \"continue\", \"label\": \"Continue\"}, {\"value\": \"stop\", \"label\": \"Stop\"}],", + ' options=[{"value": "continue", "label": "Continue"}, {"value": "stop", "label": "Stop"}],', ")", - "if approval != \"continue\":", - " ctx.refused(\"the operator declined to continue\")" - ); + 'if approval != "continue":', + ' ctx.refused("the operator declined to continue")', + ) } if (pattern.actionId) { body.push( "", - "action = ctx.run_command(" + quoted(pattern.actionId) + ", " + quoted(pattern.actionCommand) + ", 600)", - "ctx.require(action.exit_code == 0, " + quoted(pattern.actionClaim) + ", action)" - ); + "action = ctx.run_command(" + + quoted(pattern.actionId) + + ", " + + quoted(pattern.actionCommand) + + ", 600)", + "ctx.require(action.exit_code == 0, " + quoted(pattern.actionClaim) + ", action)", + ) } if (pattern.verifyId) { body.push( "", - "verify = ctx.run_command(" + quoted(pattern.verifyId) + ", " + quoted(pattern.verifyCommand) + ", 300)", - "ctx.require(verify.exit_code == 0, " + quoted(pattern.verifyClaim) + ", verify)" - ); + "verify = ctx.run_command(" + + quoted(pattern.verifyId) + + ", " + + quoted(pattern.verifyCommand) + + ", 300)", + "ctx.require(verify.exit_code == 0, " + quoted(pattern.verifyClaim) + ", verify)", + ) } } function renderPython(pattern) { const body = [ - "preflight = ctx.run_command(" + quoted(pattern.preflightId) + ", " + quoted(pattern.preflightCommand) + ", 300)", + "preflight = ctx.run_command(" + + quoted(pattern.preflightId) + + ", " + + quoted(pattern.preflightCommand) + + ", 300)", "ctx.require(preflight.exit_code == 0, " + quoted(pattern.preflightClaim) + ", preflight)", "", "decision = ctx.agent_task(", " " + quoted(pattern.decisionId) + ",", " " + quoted(pattern.instruction) + ",", - " context={\"stdout\": preflight.stdout, \"stderr\": preflight.stderr},", + ' context={"stdout": preflight.stdout, "stderr": preflight.stderr},', " schema=DECISION_SCHEMA,", ")", - "ctx.require(decision[\"status\"] == \"pass\" and decision[\"critical\"] == 0, " + quoted(pattern.decisionClaim) + ", decision)" - ]; - addOptionalPythonSteps(body, pattern); - body.push("", ...resultLines(pattern, "python")); + 'ctx.require(decision["status"] == "pass" and decision["critical"] == 0, ' + + quoted(pattern.decisionClaim) + + ", decision)", + ] + addOptionalPythonSteps(body, pattern) + body.push("", ...resultLines(pattern, "python")) return [ "# " + pattern.title + ". Replace the illustrative commands with your project commands.", "import sys", "from pathlib import Path", "", - "sys.path.insert(0, str(Path(__file__).resolve().parents[4] / \"sdk\" / \"python\"))", + 'sys.path.insert(0, str(Path(__file__).resolve().parents[4] / "sdk" / "python"))', "from yieldskill import define_skill # noqa: E402", "", "DECISION_SCHEMA = " + JSON.stringify(decisionSchema, null, 2), @@ -299,8 +348,8 @@ function renderPython(pattern) { ...indent(body, 4), "", "define_skill(program)", - "" - ].join("\n"); + "", + ].join("\n") } function addOptionalGoSteps(body, pattern) { @@ -310,64 +359,78 @@ function addOptionalGoSteps(body, pattern) { "approval := ctx.AskUser(", " " + quoted(pattern.approvalId) + ",", " " + quoted(pattern.approvalQuestion) + ",", - " yield.Option{Value: \"continue\", Label: \"Continue\"},", - " yield.Option{Value: \"stop\", Label: \"Stop\"},", + ' yield.Option{Value: "continue", Label: "Continue"},', + ' yield.Option{Value: "stop", Label: "Stop"},', ")", - "if approval != \"continue\" {", - " return yield.Outcome{}, ctx.Refused(\"the operator declined to continue\")", - "}" - ); + 'if approval != "continue" {', + ' return yield.Outcome{}, ctx.Refused("the operator declined to continue")', + "}", + ) } if (pattern.actionId) { body.push( "", - "action := ctx.RunCommand(" + quoted(pattern.actionId) + ", " + quoted(pattern.actionCommand) + ", 600)", - "ctx.Require(action.ExitCode == 0, " + quoted(pattern.actionClaim) + ", action)" - ); + "action := ctx.RunCommand(" + + quoted(pattern.actionId) + + ", " + + quoted(pattern.actionCommand) + + ", 600)", + "ctx.Require(action.ExitCode == 0, " + quoted(pattern.actionClaim) + ", action)", + ) } if (pattern.verifyId) { body.push( "", - "verify := ctx.RunCommand(" + quoted(pattern.verifyId) + ", " + quoted(pattern.verifyCommand) + ", 300)", - "ctx.Require(verify.ExitCode == 0, " + quoted(pattern.verifyClaim) + ", verify)" - ); + "verify := ctx.RunCommand(" + + quoted(pattern.verifyId) + + ", " + + quoted(pattern.verifyCommand) + + ", 300)", + "ctx.Require(verify.ExitCode == 0, " + quoted(pattern.verifyClaim) + ", verify)", + ) } } function renderGo(pattern) { - const goTick = String.fromCharCode(96); + const goTick = String.fromCharCode(96) const body = [ - "preflight := ctx.RunCommand(" + quoted(pattern.preflightId) + ", " + quoted(pattern.preflightCommand) + ", 300)", + "preflight := ctx.RunCommand(" + + quoted(pattern.preflightId) + + ", " + + quoted(pattern.preflightCommand) + + ", 300)", "ctx.Require(preflight.ExitCode == 0, " + quoted(pattern.preflightClaim) + ", preflight)", "", "raw := ctx.AgentTask(", " " + quoted(pattern.decisionId) + ",", " " + quoted(pattern.instruction) + ",", - " map[string]any{\"stdout\": preflight.Stdout, \"stderr\": preflight.Stderr},", + ' map[string]any{"stdout": preflight.Stdout, "stderr": preflight.Stderr},', " json.RawMessage(decisionSchema),", ")", "var decision decision", "if err := json.Unmarshal(raw, &decision); err != nil {", " return yield.Outcome{}, err", "}", - "ctx.Require(decision.Status == \"pass\" && decision.Critical == 0, " + quoted(pattern.decisionClaim) + ", decision)" - ]; - addOptionalGoSteps(body, pattern); - body.push("", ...resultLines(pattern, "go")); + 'ctx.Require(decision.Status == "pass" && decision.Critical == 0, ' + + quoted(pattern.decisionClaim) + + ", decision)", + ] + addOptionalGoSteps(body, pattern) + body.push("", ...resultLines(pattern, "go")) return [ "// " + pattern.title + ". Replace the illustrative commands with your project commands.", "package main", "", "import (", - " \"encoding/json\"", + ' "encoding/json"', "", - " \"github.com/operatorstack/yield/sdk/yield\"", + ' "github.com/operatorstack/yield/sdk/yield"', ")", "", "type decision struct {", - " Status string " + goTick + "json:\"status\"" + goTick, - " Critical int " + goTick + "json:\"critical\"" + goTick, - " Summary string " + goTick + "json:\"summary\"" + goTick, + " Status string " + goTick + 'json:"status"' + goTick, + " Critical int " + goTick + 'json:"critical"' + goTick, + " Summary string " + goTick + 'json:"summary"' + goTick, "}", "", "const decisionSchema = " + goTick + JSON.stringify(decisionSchema) + goTick, @@ -377,8 +440,8 @@ function renderGo(pattern) { ...indent(body, 4), " })", "}", - "" - ].join("\n"); + "", + ].join("\n") } function addOptionalRustSteps(body, pattern) { @@ -388,60 +451,72 @@ function addOptionalRustSteps(body, pattern) { "let approval = ctx.ask_user(", " " + quoted(pattern.approvalId) + ",", " " + quoted(pattern.approvalQuestion) + ",", - " &[(\"continue\", \"Continue\"), (\"stop\", \"Stop\")],", + ' &[("continue", "Continue"), ("stop", "Stop")],', ");", - "if approval != \"continue\" {", - " return Err(ctx.refused(\"the operator declined to continue\"));", - "}" - ); + 'if approval != "continue" {', + ' return Err(ctx.refused("the operator declined to continue"));', + "}", + ) } if (pattern.actionId) { body.push( "", - "let action = ctx.run_command(" + quoted(pattern.actionId) + ", " + quoted(pattern.actionCommand) + ", 600);", + "let action = ctx.run_command(" + + quoted(pattern.actionId) + + ", " + + quoted(pattern.actionCommand) + + ", 600);", "ctx.require(", " action.exit_code == 0,", " " + quoted(pattern.actionClaim) + ",", - " Some(&json!({\"exit_code\": action.exit_code})),", - ");" - ); + ' Some(&json!({"exit_code": action.exit_code})),', + ");", + ) } if (pattern.verifyId) { body.push( "", - "let verify = ctx.run_command(" + quoted(pattern.verifyId) + ", " + quoted(pattern.verifyCommand) + ", 300);", + "let verify = ctx.run_command(" + + quoted(pattern.verifyId) + + ", " + + quoted(pattern.verifyCommand) + + ", 300);", "ctx.require(", " verify.exit_code == 0,", " " + quoted(pattern.verifyClaim) + ",", - " Some(&json!({\"exit_code\": verify.exit_code})),", - ");" - ); + ' Some(&json!({"exit_code": verify.exit_code})),', + ");", + ) } } function renderRust(pattern) { const body = [ - "let preflight = ctx.run_command(" + quoted(pattern.preflightId) + ", " + quoted(pattern.preflightCommand) + ", 300);", + "let preflight = ctx.run_command(" + + quoted(pattern.preflightId) + + ", " + + quoted(pattern.preflightCommand) + + ", 300);", "ctx.require(", " preflight.exit_code == 0,", " " + quoted(pattern.preflightClaim) + ",", - " Some(&json!({\"exit_code\": preflight.exit_code})),", + ' Some(&json!({"exit_code": preflight.exit_code})),', ");", "", "let decision = ctx.agent_task(", " " + quoted(pattern.decisionId) + ",", " " + quoted(pattern.instruction) + ",", - " Some(json!({\"stdout\": preflight.stdout, \"stderr\": preflight.stderr})),", + ' Some(json!({"stdout": preflight.stdout, "stderr": preflight.stderr})),', " Some(decision_schema()),", ");", "ctx.require(", - " decision[\"status\"] == \"pass\" && decision[\"critical\"] == 0,", + ' decision["status"] == "pass" && decision["critical"] == 0,', " " + quoted(pattern.decisionClaim) + ",", " Some(&decision),", - ");" - ]; - addOptionalRustSteps(body, pattern); - body.push("", ...resultLines(pattern, "rust")); + ");", + ] + addOptionalRustSteps(body, pattern) + body.push("", ...resultLines(pattern, "rust")) return [ "// " + pattern.title + ". Replace the illustrative commands with your project commands.", "use serde_json::{json, Value};", @@ -458,8 +533,8 @@ function renderRust(pattern) { "fn main() {", " define_skill(program);", "}", - "" - ].join("\n"); + "", + ].join("\n") } function renderSkill(pattern) { @@ -477,8 +552,8 @@ function renderSkill(pattern) { "The program owns order, approval, commands, and finish rules. The agent", "owns judgment inside each agent task. Replace the illustrative commands", "with the real commands from your repository before using this workflow.", - "" - ].join("\n"); + "", + ].join("\n") } function renderFixture(pattern) { @@ -486,72 +561,86 @@ function renderFixture(pattern) { [pattern.decisionId]: { status: "pass", critical: 0, - summary: "Fixture result: " + pattern.summary - } - }; - if (pattern.approvalId) fixture[pattern.approvalId] = { value: "continue" }; - return JSON.stringify(fixture, null, 2) + "\n"; + summary: "Fixture result: " + pattern.summary, + }, + } + if (pattern.approvalId) fixture[pattern.approvalId] = { value: "continue" } + return JSON.stringify(fixture, null, 2) + "\n" } function manifestFor(language, slug) { - if (language === "typescript") return { run: ["node", "../src/" + slug + ".ts"] }; - if (language === "python") return { run: ["python3", "../src/" + slug + ".py"] }; - if (language === "go") return { run: ["go", "run", "../src/" + slug + "/main.go"] }; - return { run: ["cargo", "run", "--quiet", "--manifest-path", "../Cargo.toml", "--bin", slug] }; + if (language === "typescript") return { run: ["node", "../src/" + slug + ".ts"] } + if (language === "python") return { run: ["python3", "../src/" + slug + ".py"] } + if (language === "go") return { run: ["go", "run", "../src/" + slug + "/main.go"] } + return { run: ["cargo", "run", "--quiet", "--manifest-path", "../Cargo.toml", "--bin", slug] } } async function write(path, content) { - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, content); + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, content) } -await write(join(libraryDir, "catalog.json"), JSON.stringify(patterns.map((pattern) => ({ - slug: pattern.slug, - title: pattern.title, - summary: pattern.summary, - languages -})), null, 2) + "\n"); +await write( + join(libraryDir, "catalog.json"), + JSON.stringify( + patterns.map((pattern) => ({ + slug: pattern.slug, + title: pattern.title, + summary: pattern.summary, + languages, + })), + null, + 2, + ) + "\n", +) -await write(join(libraryDir, "rust", "Cargo.toml"), [ - "[package]", - "name = \"yield-example-library\"", - "version = \"0.1.0\"", - "edition = \"2021\"", - "publish = false", - "", - "[dependencies]", - "yieldskill = { path = \"../../../sdk/rust\" }", - "serde_json = \"1\"", - "" -].join("\n")); +await write( + join(libraryDir, "rust", "Cargo.toml"), + [ + "[package]", + 'name = "yield-example-library"', + 'version = "0.1.0"', + 'edition = "2021"', + "publish = false", + "", + "[dependencies]", + 'yieldskill = { path = "../../../sdk/rust" }', + 'serde_json = "1"', + "", + ].join("\n"), +) -const goSources = []; +const goSources = [] for (const pattern of patterns) { const sources = { typescript: renderTypeScript(pattern), python: renderPython(pattern), go: renderGo(pattern), - rust: renderRust(pattern) - }; - const extensions = { typescript: "ts", python: "py", go: "go", rust: "rs" }; + rust: renderRust(pattern), + } + const extensions = { typescript: "ts", python: "py", go: "go", rust: "rs" } for (const language of languages) { - const sourceDir = language === "rust" - ? join(libraryDir, language, "src", "bin") - : language === "go" - ? join(libraryDir, language, "src", pattern.slug) - : join(libraryDir, language, "src"); - const sourceName = language === "go" ? "main.go" : pattern.slug + "." + extensions[language]; - const sourcePath = join(sourceDir, sourceName); - await write(sourcePath, sources[language]); - if (language === "go") goSources.push(sourcePath); - const skillDir = join(libraryDir, language, pattern.slug); - await write(join(skillDir, "SKILL.md"), renderSkill(pattern)); - await write(join(skillDir, "skill.json"), JSON.stringify(manifestFor(language, pattern.slug), null, 2) + "\n"); - await write(join(skillDir, "fixtures", "responses.json"), renderFixture(pattern)); + const sourceDir = + language === "rust" + ? join(libraryDir, language, "src", "bin") + : language === "go" + ? join(libraryDir, language, "src", pattern.slug) + : join(libraryDir, language, "src") + const sourceName = language === "go" ? "main.go" : pattern.slug + "." + extensions[language] + const sourcePath = join(sourceDir, sourceName) + await write(sourcePath, sources[language]) + if (language === "go") goSources.push(sourcePath) + const skillDir = join(libraryDir, language, pattern.slug) + await write(join(skillDir, "SKILL.md"), renderSkill(pattern)) + await write( + join(skillDir, "skill.json"), + JSON.stringify(manifestFor(language, pattern.slug), null, 2) + "\n", + ) + await write(join(skillDir, "fixtures", "responses.json"), renderFixture(pattern)) } } -await execFile("gofmt", ["-w", ...goSources]); -await execFile("cargo", ["fmt", "--manifest-path", join(libraryDir, "rust", "Cargo.toml")]); +await execFile("gofmt", ["-w", ...goSources]) +await execFile("cargo", ["fmt", "--manifest-path", join(libraryDir, "rust", "Cargo.toml")]) -console.log("generated " + patterns.length + " patterns in " + languages.length + " languages"); +console.log("generated " + patterns.length + " patterns in " + languages.length + " languages") diff --git a/examples/library/test-all.sh b/examples/library/test-all.sh index 157c41b..dcd3721 100644 --- a/examples/library/test-all.sh +++ b/examples/library/test-all.sh @@ -6,17 +6,17 @@ yskill="${YSKILL:-yskill}" count=0 for language in typescript python go rust; do - for skill_dir in "$library_dir/$language"/*; do - [[ -f "$skill_dir/fixtures/responses.json" ]] || continue - echo "==> ${language}/$(basename "$skill_dir")" - "$yskill" test "$skill_dir" - count=$((count + 1)) - done + for skill_dir in "$library_dir/$language"/*; do + [[ -f "$skill_dir/fixtures/responses.json" ]] || continue + echo "==> ${language}/$(basename "$skill_dir")" + "$yskill" test "$skill_dir" + count=$((count + 1)) + done done if [[ "$count" -ne 40 ]]; then - echo "expected 40 fixture runs, found $count" >&2 - exit 1 + echo "expected 40 fixture runs, found $count" >&2 + exit 1 fi echo "validated $count example workflows" diff --git a/examples/release-checklist/main.ts b/examples/release-checklist/main.ts index 6629430..68f1614 100644 --- a/examples/release-checklist/main.ts +++ b/examples/release-checklist/main.ts @@ -1,16 +1,16 @@ // Example skill (TypeScript): a release checklist where tests, review, // approval, publishing, and registry verification cannot be skipped. -import { defineSkill } from "../../sdk/typescript/src/index.ts"; +import { defineSkill } from "../../sdk/typescript/src/index.ts" // README_EXAMPLE_START -type Review = { critical: number; summary: string }; +type Review = { critical: number; summary: string } defineSkill((ctx) => { // Yield runs commands itself and records their output and exit status. - const tests = ctx.runCommand("test", "echo tests-ok", 300); + const tests = ctx.runCommand("test", "echo tests-ok", 300) // A failed requirement stops the workflow and keeps its evidence. - ctx.require(tests.exit_code === 0, "the test command succeeds", tests); + ctx.require(tests.exit_code === 0, "the test command succeeds", tests) // Review gives TypeScript its compile-time type. The JSON schema checks the // coding agent's response at runtime before this workflow can continue. @@ -26,25 +26,25 @@ defineSkill((ctx) => { summary: { type: "string", minLength: 1 }, }, }, - ); - ctx.require(review.critical === 0, "the review has no critical findings", review); + ) + ctx.require(review.critical === 0, "the review has no critical findings", review) // Yield emits these fixed choices. A supported host may show native controls; // otherwise the coding agent asks through its normal interface. const approval = ctx.askUser("approve-publish", "Publish this package?", [ { value: "yes", label: "Publish" }, { value: "no", label: "Stop" }, - ]); - if (approval !== "yes") ctx.refused("the operator declined publication"); + ]) + if (approval !== "yes") ctx.refused("the operator declined publication") // Publishing cannot start before approval. Verification is a separate step, // so completion requires evidence that the registry contains the release. - const publish = ctx.runCommand("publish", "echo publish-ok", 600); - ctx.require(publish.exit_code === 0, "the publish command succeeds", publish); + const publish = ctx.runCommand("publish", "echo publish-ok", 600) + ctx.require(publish.exit_code === 0, "the publish command succeeds", publish) - const registry = ctx.runCommand("verify-registry", "echo registry-ok", 300); - ctx.require(registry.exit_code === 0, "the registry contains the release", registry); + const registry = ctx.runCommand("verify-registry", "echo registry-ok", 300) + ctx.require(registry.exit_code === 0, "the registry contains the release", registry) - return { published: true, summary: review.summary }; -}); + return { published: true, summary: review.summary } +}) // README_EXAMPLE_END diff --git a/examples/release-checklist/skill.json b/examples/release-checklist/skill.json index a09b572..e54de2c 100644 --- a/examples/release-checklist/skill.json +++ b/examples/release-checklist/skill.json @@ -1,8 +1,5 @@ { "version": 1, "language": "typescript", - "run": [ - "node", - "main.ts" - ] + "run": ["node", "main.ts"] } diff --git a/ir/README.md b/ir/README.md index 341e949..410aff3 100644 --- a/ir/README.md +++ b/ir/README.md @@ -9,12 +9,12 @@ surface and nothing else. ## Files -| schema | what it defines | -|---|---| -| `yield.v1/request-envelope.schema.json` | one yielded operation, bound to run, sequence, and skill digest | -| `yield.v1/response-envelope.schema.json` | the answer for exactly one pending request | -| `yield.v1/journal.schema.json` | the replay input: run identity + answered operations in order | -| `yield.v1/program-output.schema.json` | the single output of one skill-program execution: `request` \| `terminal` \| `diverged` | +| schema | what it defines | +| ---------------------------------------- | --------------------------------------------------------------------------------------- | +| `yield.v1/request-envelope.schema.json` | one yielded operation, bound to run, sequence, and skill digest | +| `yield.v1/response-envelope.schema.json` | the answer for exactly one pending request | +| `yield.v1/journal.schema.json` | the replay input: run identity + answered operations in order | +| `yield.v1/program-output.schema.json` | the single output of one skill-program execution: `request` \| `terminal` \| `diverged` | ## The SDK execution contract diff --git a/ir/yield.v1/journal.schema.json b/ir/yield.v1/journal.schema.json index 9f59cd1..4f1f880 100644 --- a/ir/yield.v1/journal.schema.json +++ b/ir/yield.v1/journal.schema.json @@ -2,10 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "yield.v1 journal (replay input)", "type": "object", - "required": [ - "run_id", - "skill" - ], + "required": ["run_id", "skill"], "properties": { "run_id": { "type": "string", @@ -18,10 +15,7 @@ "type": "array", "items": { "type": "object", - "required": [ - "request", - "response" - ], + "required": ["request", "response"], "properties": { "request": { "$ref": "request-envelope.schema.json#/$defs/request" diff --git a/ir/yield.v1/request-envelope.schema.json b/ir/yield.v1/request-envelope.schema.json index b25d437..d7891dd 100644 --- a/ir/yield.v1/request-envelope.schema.json +++ b/ir/yield.v1/request-envelope.schema.json @@ -2,13 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "yield.v1 request envelope", "type": "object", - "required": [ - "protocol", - "run_id", - "skill", - "sequence", - "request" - ], + "required": ["protocol", "run_id", "skill", "sequence", "request"], "properties": { "protocol": { "const": "yield.v1" @@ -32,10 +26,7 @@ "$defs": { "skillRef": { "type": "object", - "required": [ - "name", - "digest" - ], + "required": ["name", "digest"], "properties": { "name": { "type": "string", @@ -53,22 +44,14 @@ }, "request": { "type": "object", - "required": [ - "id", - "kind", - "payload" - ], + "required": ["id", "kind", "payload"], "properties": { "id": { "type": "string", "minLength": 1 }, "kind": { - "enum": [ - "ask_user", - "agent_task", - "run_command" - ] + "enum": ["ask_user", "agent_task", "run_command"] }, "payload": {}, "output_schema": {} diff --git a/ir/yield.v1/response-envelope.schema.json b/ir/yield.v1/response-envelope.schema.json index a4cafe8..e78b92c 100644 --- a/ir/yield.v1/response-envelope.schema.json +++ b/ir/yield.v1/response-envelope.schema.json @@ -2,13 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "yield.v1 response envelope", "type": "object", - "required": [ - "run_id", - "sequence", - "request_id", - "status", - "result" - ], + "required": ["run_id", "sequence", "request_id", "status", "result"], "properties": { "run_id": { "type": "string", @@ -23,10 +17,7 @@ "minLength": 1 }, "status": { - "enum": [ - "completed", - "failed" - ] + "enum": ["completed", "failed"] }, "result": {} }, diff --git a/package-lock.json b/package-lock.json index f57bd39..17f2a00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "devDependencies": { "@changesets/cli": "2.31.1", "@operatorstack/yield": "0.1.38", + "@taplo/cli": "0.7.0", + "prettier": "3.9.6", "yaml": "2.9.0" } }, @@ -44,6 +46,22 @@ "semver": "^7.5.3" } }, + "node_modules/@changesets/apply-release-plan/node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/@changesets/assemble-release-plan": { "version": "6.0.10", "resolved": "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-6.0.10.tgz", @@ -264,6 +282,22 @@ "prettier": "^2.7.1" } }, + "node_modules/@changesets/write/node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/@inquirer/external-editor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", @@ -501,6 +535,16 @@ "win32" ] }, + "node_modules/@taplo/cli": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@taplo/cli/-/cli-0.7.0.tgz", + "integrity": "sha512-Ck3zFhQhIhi02Hl6T4ZmJsXdnJE+wXcJz5f8klxd4keRYgenMnip3JDPMGDRLbnC/2iGd8P0sBIQqI3KxfVjBg==", + "dev": true, + "license": "MIT", + "bin": { + "taplo": "dist/cli.js" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -1081,16 +1125,16 @@ } }, "node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { - "prettier": "bin-prettier.js" + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=10.13.0" + "node": ">=14" }, "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" diff --git a/package.json b/package.json index 58b97eb..3aa9c30 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "type": "module", "scripts": { "changeset": "changeset", + "format": "node scripts/format.mjs --write", + "format:check": "node scripts/format.mjs --check", "postinstall": "node scripts/prepare-selfhost.mjs", "prepare:selfhost": "node scripts/prepare-selfhost.mjs", "release:plan": "node scripts/release-plan.mjs", @@ -11,8 +13,10 @@ "test:selfhost": "node --test skills/release-yield/src/*.test.mjs" }, "devDependencies": { - "@operatorstack/yield": "0.1.38", "@changesets/cli": "2.31.1", + "@operatorstack/yield": "0.1.38", + "@taplo/cli": "0.7.0", + "prettier": "3.9.6", "yaml": "2.9.0" } } diff --git a/packaging/assemble.mjs b/packaging/assemble.mjs index 2cb36ab..c784cdf 100644 --- a/packaging/assemble.mjs +++ b/packaging/assemble.mjs @@ -1,161 +1,233 @@ #!/usr/bin/env node -import { chmod, cp, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; -import { basename, join, resolve } from "node:path"; -import { createHash } from "node:crypto"; -import process from "node:process"; -import { binaryName, npmPackage, rustPackage, targets } from "./targets.mjs"; +import { chmod, cp, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises" +import { basename, join, resolve } from "node:path" +import { createHash } from "node:crypto" +import process from "node:process" +import { binaryName, npmPackage, rustPackage, targets } from "./targets.mjs" -const root = resolve(import.meta.dirname, ".."); -const stableVersion = /^\d+\.\d+\.\d+$/; -const canaryVersion = /^0\.0\.0-canary\.\d{14}\.[0-9a-f]{12}$/; +const root = resolve(import.meta.dirname, "..") +const stableVersion = /^\d+\.\d+\.\d+$/ +const canaryVersion = /^0\.0\.0-canary\.\d{14}\.[0-9a-f]{12}$/ export function isPackageVersion(value) { - return stableVersion.test(value) || canaryVersion.test(value); + return stableVersion.test(value) || canaryVersion.test(value) } export function npmReadme(readme) { - return readme.replace(/\s*[\s\S]*?/g, ""); + return readme.replace(/\s*[\s\S]*?/g, "") } function parseArgs(argv) { - const values = {}; - for (let index = 0; index < argv.length; index += 2) values[argv[index]?.replace(/^--/, "")] = argv[index + 1]; - if (!isPackageVersion(values.version ?? "")) throw new Error("--version must be stable semver or a Yield canary version"); - if (!values.binaries || !values.output) throw new Error("--binaries and --output are required"); - return { version: values.version, binaries: resolve(values.binaries), output: resolve(values.output) }; + const values = {} + for (let index = 0; index < argv.length; index += 2) + values[argv[index]?.replace(/^--/, "")] = argv[index + 1] + if (!isPackageVersion(values.version ?? "")) + throw new Error("--version must be stable semver or a Yield canary version") + if (!values.binaries || !values.output) throw new Error("--binaries and --output are required") + return { + version: values.version, + binaries: resolve(values.binaries), + output: resolve(values.output), + } } async function json(path) { - return JSON.parse(await readFile(path, "utf8")); + return JSON.parse(await readFile(path, "utf8")) } async function sha256(path) { - return createHash("sha256").update(await readFile(path)).digest("hex"); + return createHash("sha256") + .update(await readFile(path)) + .digest("hex") } async function copyBinary(source, destination, executable = true) { - await mkdir(resolve(destination, ".."), { recursive: true }); - await cp(source, destination); - if (executable && !destination.endsWith(".exe")) await chmod(destination, 0o755); - if (!executable) await chmod(destination, 0o644); + await mkdir(resolve(destination, ".."), { recursive: true }) + await cp(source, destination) + if (executable && !destination.endsWith(".exe")) await chmod(destination, 0o755) + if (!executable) await chmod(destination, 0o644) } async function validateBinaries(directory) { - const records = []; + const records = [] for (const target of targets) { - const path = join(directory, binaryName(target)); - const details = await stat(path).catch(() => null); - if (!details?.isFile() || details.size === 0) throw new Error(`missing runtime ${path}`); - records.push({ target: target.id, file: basename(path), bytes: details.size, sha256: await sha256(path) }); + const path = join(directory, binaryName(target)) + const details = await stat(path).catch(() => null) + if (!details?.isFile() || details.size === 0) throw new Error(`missing runtime ${path}`) + records.push({ + target: target.id, + file: basename(path), + bytes: details.size, + sha256: await sha256(path), + }) } - return records; + return records } async function assembleNpm({ version, binaries, output }) { - const npm = join(output, "npm"); - const main = join(npm, "yield"); - const initializer = join(npm, "create-yield"); - await cp(join(root, "sdk/typescript"), main, { recursive: true, filter: (source) => !source.includes("node_modules") && !source.includes("/dist") }); - await mkdir(join(main, "assets"), { recursive: true }); + const npm = join(output, "npm") + const main = join(npm, "yield") + const initializer = join(npm, "create-yield") + await cp(join(root, "sdk/typescript"), main, { + recursive: true, + filter: (source) => !source.includes("node_modules") && !source.includes("/dist"), + }) + await mkdir(join(main, "assets"), { recursive: true }) const [readme] = await Promise.all([ readFile(join(root, "README.md"), "utf8"), cp(join(root, "LICENSE"), join(main, "LICENSE")), cp(join(root, "assets/yield-mark.svg"), join(main, "assets/yield-mark.svg")), - ]); - await writeFile(join(main, "README.md"), npmReadme(readme)); - const packageJson = await json(join(main, "package.json")); - packageJson.version = version; + ]) + await writeFile(join(main, "README.md"), npmReadme(readme)) + const packageJson = await json(join(main, "package.json")) + packageJson.version = version packageJson.publishConfig = { access: "public", provenance: true, registry: "https://registry.npmjs.org/", - }; - packageJson.optionalDependencies = Object.fromEntries(targets.map((target) => [npmPackage(target), version])); - packageJson.files = [...new Set([...(packageJson.files ?? []), "assets"])]; - await writeFile(join(main, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`); - - await cp(join(root, "packaging/create-yield"), initializer, { recursive: true }); - await cp(join(root, "LICENSE"), join(initializer, "LICENSE")); - await chmod(join(initializer, "bin/create-yield.mjs"), 0o755); - const initializerPackage = await json(join(initializer, "package.json")); - initializerPackage.version = version; - initializerPackage.dependencies["@operatorstack/yield"] = version; - await writeFile(join(initializer, "package.json"), `${JSON.stringify(initializerPackage, null, 2)}\n`); + } + packageJson.optionalDependencies = Object.fromEntries( + targets.map((target) => [npmPackage(target), version]), + ) + packageJson.files = [...new Set([...(packageJson.files ?? []), "assets"])] + await writeFile(join(main, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`) + + await cp(join(root, "packaging/create-yield"), initializer, { recursive: true }) + await cp(join(root, "LICENSE"), join(initializer, "LICENSE")) + await chmod(join(initializer, "bin/create-yield.mjs"), 0o755) + const initializerPackage = await json(join(initializer, "package.json")) + initializerPackage.version = version + initializerPackage.dependencies["@operatorstack/yield"] = version + await writeFile( + join(initializer, "package.json"), + `${JSON.stringify(initializerPackage, null, 2)}\n`, + ) for (const target of targets) { - const directory = join(npm, target.id); - const runtime = target.goos === "windows" ? "yskill.exe" : "yskill"; - await mkdir(directory, { recursive: true }); - await copyBinary(join(binaries, binaryName(target)), join(directory, runtime)); - await cp(join(root, "LICENSE"), join(directory, "LICENSE")); - 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" }, - publishConfig: { access: "public", provenance: true, registry: "https://registry.npmjs.org/" }, - }, null, 2)}\n`); + const directory = join(npm, target.id) + const runtime = target.goos === "windows" ? "yskill.exe" : "yskill" + await mkdir(directory, { recursive: true }) + await copyBinary(join(binaries, binaryName(target)), join(directory, runtime)) + await cp(join(root, "LICENSE"), join(directory, "LICENSE")) + 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" }, + publishConfig: { + access: "public", + provenance: true, + registry: "https://registry.npmjs.org/", + }, + }, + null, + 2, + )}\n`, + ) } } async function assemblePython({ version, binaries, output }) { - const python = join(output, "python"); + const python = join(output, "python") for (const target of targets) { - const directory = join(python, target.id); - await cp(join(root, "sdk/python"), directory, { recursive: true, filter: (source) => !source.includes("__pycache__") && !source.includes("/dist") && !source.includes("/build") }); - const pyproject = (await readFile(join(directory, "pyproject.toml"), "utf8")).replace(/^version = ".*"/m, `version = "${version}"`); - await writeFile(join(directory, "pyproject.toml"), pyproject); - const runtime = target.goos === "windows" ? "yskill.exe" : "yskill"; - await copyBinary(join(binaries, binaryName(target)), join(directory, "yieldskill/_runtime", runtime)); - await writeFile(join(directory, "setup.py"), `from wheel.bdist_wheel import bdist_wheel\nfrom setuptools import setup\n\nclass PlatformWheel(bdist_wheel):\n def finalize_options(self):\n super().finalize_options()\n self.root_is_pure = False\n def get_tag(self):\n return ("py3", "none", "${target.pythonTag}")\n\nsetup(cmdclass={"bdist_wheel": PlatformWheel})\n`); + const directory = join(python, target.id) + await cp(join(root, "sdk/python"), directory, { + recursive: true, + filter: (source) => + !source.includes("__pycache__") && !source.includes("/dist") && !source.includes("/build"), + }) + const pyproject = (await readFile(join(directory, "pyproject.toml"), "utf8")).replace( + /^version = ".*"/m, + `version = "${version}"`, + ) + await writeFile(join(directory, "pyproject.toml"), pyproject) + const runtime = target.goos === "windows" ? "yskill.exe" : "yskill" + await copyBinary( + join(binaries, binaryName(target)), + join(directory, "yieldskill/_runtime", runtime), + ) + await writeFile( + join(directory, "setup.py"), + `from wheel.bdist_wheel import bdist_wheel\nfrom setuptools import setup\n\nclass PlatformWheel(bdist_wheel):\n def finalize_options(self):\n super().finalize_options()\n self.root_is_pure = False\n def get_tag(self):\n return ("py3", "none", "${target.pythonTag}")\n\nsetup(cmdclass={"bdist_wheel": PlatformWheel})\n`, + ) } } function rustDependency(target, version) { - return `[target.'cfg(all(target_os = "${target.rustOs}", target_arch = "${target.rustArch}"))'.dependencies]\n${rustPackage(target)} = { version = "=${version}" }\n`; + return `[target.'cfg(all(target_os = "${target.rustOs}", target_arch = "${target.rustArch}"))'.dependencies]\n${rustPackage(target)} = { version = "=${version}" }\n` } async function assembleRust({ version, binaries, output }, records) { - const rust = join(output, "rust"); - const runtimeByTarget = new Map(records.map((record) => [record.target, record])); + const rust = join(output, "rust") + const runtimeByTarget = new Map(records.map((record) => [record.target, record])) for (const target of targets) { - const name = rustPackage(target); - const directory = join(rust, "runtime", target.id); - const runtime = target.goos === "windows" ? "yskill.exe" : "yskill"; - await mkdir(join(directory, "src"), { recursive: true }); - await copyBinary(join(binaries, binaryName(target)), join(directory, "runtime", runtime), false); - await cp(join(root, "LICENSE"), join(directory, "LICENSE")); - await writeFile(join(directory, "README.md"), `# ${name}\n\nPlatform runtime support for [Yield](https://crates.io/crates/yieldskill) on ${target.id}.\n\nThis crate is installed automatically by \`yieldskill\`. Do not add it directly.\n`); - await writeFile(join(directory, "Cargo.toml"), `[package]\nname = "${name}"\nversion = "${version}"\nedition = "2021"\nlicense = "MIT"\ndescription = "Yield runtime support for ${target.id}."\nrepository = "https://github.com/operatorstack/yield"\nhomepage = "https://yield.operatorstack.systems/"\nreadme = "README.md"\ninclude = ["src/lib.rs", "runtime/${runtime}", "README.md", "LICENSE"]\n\n[lib]\npath = "src/lib.rs"\n`); - await writeFile(join(directory, "src/lib.rs"), `pub const BYTES: &[u8] = include_bytes!("../runtime/${runtime}");\npub const SHA256: &str = "${runtimeByTarget.get(target.id).sha256}";\n`); + const name = rustPackage(target) + const directory = join(rust, "runtime", target.id) + const runtime = target.goos === "windows" ? "yskill.exe" : "yskill" + await mkdir(join(directory, "src"), { recursive: true }) + await copyBinary(join(binaries, binaryName(target)), join(directory, "runtime", runtime), false) + await cp(join(root, "LICENSE"), join(directory, "LICENSE")) + await writeFile( + join(directory, "README.md"), + `# ${name}\n\nPlatform runtime support for [Yield](https://crates.io/crates/yieldskill) on ${target.id}.\n\nThis crate is installed automatically by \`yieldskill\`. Do not add it directly.\n`, + ) + await writeFile( + join(directory, "Cargo.toml"), + `[package]\nname = "${name}"\nversion = "${version}"\nedition = "2021"\nlicense = "MIT"\ndescription = "Yield runtime support for ${target.id}."\nrepository = "https://github.com/operatorstack/yield"\nhomepage = "https://yield.operatorstack.systems/"\nreadme = "README.md"\ninclude = ["src/lib.rs", "runtime/${runtime}", "README.md", "LICENSE"]\n\n[lib]\npath = "src/lib.rs"\n`, + ) + await writeFile( + join(directory, "src/lib.rs"), + `pub const BYTES: &[u8] = include_bytes!("../runtime/${runtime}");\npub const SHA256: &str = "${runtimeByTarget.get(target.id).sha256}";\n`, + ) } - const main = join(rust, "yieldskill"); - await cp(join(root, "sdk/rust"), main, { recursive: true, filter: (source) => !source.includes("/target") }); - await cp(join(root, "LICENSE"), join(main, "LICENSE")); - let cargo = (await readFile(join(main, "Cargo.toml"), "utf8")).replace(/^version = ".*"/m, `version = "${version}"`); - cargo += `\n[[bin]]\nname = "yskill"\npath = "src/bin/yskill.rs"\n\n${targets.map((target) => rustDependency(target, version)).join("\n")}`; - await writeFile(join(main, "Cargo.toml"), cargo); - await mkdir(join(main, "src/bin"), { recursive: true }); - await cp(join(root, "packaging/rust-launcher.rs"), join(main, "src/bin/yskill.rs")); + const main = join(rust, "yieldskill") + await cp(join(root, "sdk/rust"), main, { + recursive: true, + filter: (source) => !source.includes("/target"), + }) + await cp(join(root, "LICENSE"), join(main, "LICENSE")) + let cargo = (await readFile(join(main, "Cargo.toml"), "utf8")).replace( + /^version = ".*"/m, + `version = "${version}"`, + ) + cargo += `\n[[bin]]\nname = "yskill"\npath = "src/bin/yskill.rs"\n\n${targets.map((target) => rustDependency(target, version)).join("\n")}` + await writeFile(join(main, "Cargo.toml"), cargo) + await mkdir(join(main, "src/bin"), { recursive: true }) + await cp(join(root, "packaging/rust-launcher.rs"), join(main, "src/bin/yskill.rs")) } export async function assemble(options) { - await rm(options.output, { recursive: true, force: true }); - await mkdir(options.output, { recursive: true }); - const records = await validateBinaries(options.binaries); - await Promise.all([assembleNpm(options), assemblePython(options), assembleRust(options, records)]); - await writeFile(join(options.output, "SHA256SUMS.json"), `${JSON.stringify({ version: options.version, artifacts: records }, null, 2)}\n`); + await rm(options.output, { recursive: true, force: true }) + await mkdir(options.output, { recursive: true }) + const records = await validateBinaries(options.binaries) + await Promise.all([assembleNpm(options), assemblePython(options), assembleRust(options, records)]) + await writeFile( + join(options.output, "SHA256SUMS.json"), + `${JSON.stringify({ version: options.version, artifacts: records }, null, 2)}\n`, + ) } if (process.argv[1]) { const [entrypoint, modulePath] = await Promise.all([ realpath(resolve(process.argv[1])), realpath(import.meta.filename), - ]); + ]) if (entrypoint === modulePath) { - assemble(parseArgs(process.argv.slice(2))).catch((error) => { console.error(`assemble: ${error.message}`); process.exit(1); }); + assemble(parseArgs(process.argv.slice(2))).catch((error) => { + console.error(`assemble: ${error.message}`) + process.exit(1) + }) } } diff --git a/packaging/assemble.test.mjs b/packaging/assemble.test.mjs index 5428e7d..5113a9e 100644 --- a/packaging/assemble.test.mjs +++ b/packaging/assemble.test.mjs @@ -1,127 +1,150 @@ -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"; -import { assemble, isPackageVersion } from "./assemble.mjs"; -import { binaryName, npmPackage, rustPackage, targets } from "./targets.mjs"; +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" +import { assemble, isPackageVersion } from "./assemble.mjs" +import { binaryName, npmPackage, rustPackage, targets } from "./targets.mjs" -const homepage = "https://yield.operatorstack.systems/"; +const homepage = "https://yield.operatorstack.systems/" test("accepts stable and exact Yield canary versions", () => { - assert.equal(isPackageVersion("1.2.3"), true); - assert.equal(isPackageVersion("0.0.0-canary.20260807104031.b081bae38282"), true); - assert.equal(isPackageVersion("1.2.3-beta.1"), false); - assert.equal(isPackageVersion("0.0.0-canary.latest.b081bae38282"), false); - assert.equal(isPackageVersion("v1.2.3"), false); -}); + assert.equal(isPackageVersion("1.2.3"), true) + assert.equal(isPackageVersion("0.0.0-canary.20260807104031.b081bae38282"), true) + assert.equal(isPackageVersion("1.2.3-beta.1"), false) + assert.equal(isPackageVersion("0.0.0-canary.latest.b081bae38282"), false) + assert.equal(isPackageVersion("v1.2.3"), false) +}) test("assembles two public npm packages 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 })); + const root = await mkdtemp(join(tmpdir(), "yield-assemble-")) + t.after(() => rm(root, { recursive: true, force: true })) - const binaries = join(root, "bin"); - const output = join(root, "packages"); - await mkdir(binaries); + const binaries = join(root, "bin") + const output = join(root, "packages") + await mkdir(binaries) for (const target of targets) { - await writeFile(join(binaries, binaryName(target)), `runtime:${target.id}`); + await writeFile(join(binaries, binaryName(target)), `runtime:${target.id}`) } - await assemble({ version: "1.2.3", binaries, output }); - const readJson = async (path) => JSON.parse(await readFile(path, "utf8")); - const main = await readJson(join(output, "npm/yield/package.json")); - const initializer = await readJson(join(output, "npm/create-yield/package.json")); + await assemble({ version: "1.2.3", binaries, output }) + const readJson = async (path) => JSON.parse(await readFile(path, "utf8")) + const main = await readJson(join(output, "npm/yield/package.json")) + const initializer = await readJson(join(output, "npm/create-yield/package.json")) - assert.equal(main.name, "@operatorstack/yield"); - assert.equal(main.version, "1.2.3"); - assert.equal(main.homepage, homepage); + assert.equal(main.name, "@operatorstack/yield") + assert.equal(main.version, "1.2.3") + assert.equal(main.homepage, homepage) assert.deepEqual(main.publishConfig, { access: "public", provenance: true, registry: "https://registry.npmjs.org/", - }); + }) assert.deepEqual( main.optionalDependencies, Object.fromEntries(targets.map((target) => [npmPackage(target), "1.2.3"])), - ); - assert.equal(initializer.name, "@operatorstack/create-yield"); - assert.equal(initializer.version, "1.2.3"); - assert.equal(initializer.dependencies["@operatorstack/yield"], "1.2.3"); - assert.equal(initializer.publishConfig.provenance, true); - assert.match(await readFile(join(output, "npm/create-yield/LICENSE"), "utf8"), /MIT License/); - assert.match(await readFile(join(output, "npm/create-yield/bin/create-yield.mjs"), "utf8"), /bootstrap/); - const initializerPack = JSON.parse(execFileSync("npm", ["pack", "--dry-run", "--json"], { - cwd: join(output, "npm/create-yield"), - encoding: "utf8", - })); - assert.equal(initializerPack[0].files.find((file) => file.path === "bin/create-yield.mjs").mode, 0o755); - const assembledReadme = await readFile(join(output, "npm/yield/README.md"), "utf8"); - const repositoryReadme = await readFile(join(import.meta.dirname, "../README.md"), "utf8"); - assert.match(repositoryReadme, /pypi\.org\/project\/yieldskill/); - assert.match(assembledReadme, /npmjs\.com\/package\/@operatorstack\/yield/); - assert.doesNotMatch(assembledReadme, /pypi\.org|PyPI version|npm-exclude/); + ) + assert.equal(initializer.name, "@operatorstack/create-yield") + assert.equal(initializer.version, "1.2.3") + assert.equal(initializer.dependencies["@operatorstack/yield"], "1.2.3") + assert.equal(initializer.publishConfig.provenance, true) + assert.match(await readFile(join(output, "npm/create-yield/LICENSE"), "utf8"), /MIT License/) + assert.match( + await readFile(join(output, "npm/create-yield/bin/create-yield.mjs"), "utf8"), + /bootstrap/, + ) + const initializerPack = JSON.parse( + execFileSync("npm", ["pack", "--dry-run", "--json"], { + cwd: join(output, "npm/create-yield"), + encoding: "utf8", + }), + ) + assert.equal( + initializerPack[0].files.find((file) => file.path === "bin/create-yield.mjs").mode, + 0o755, + ) + const assembledReadme = await readFile(join(output, "npm/yield/README.md"), "utf8") + const repositoryReadme = await readFile(join(import.meta.dirname, "../README.md"), "utf8") + assert.match(repositoryReadme, /pypi\.org\/project\/yieldskill/) + assert.match(assembledReadme, /npmjs\.com\/package\/@operatorstack\/yield/) + assert.doesNotMatch(assembledReadme, /pypi\.org|PyPI version|npm-exclude/) assert.equal( await readFile(join(output, "npm/yield/assets/yield-mark.svg"), "utf8"), await readFile(join(import.meta.dirname, "../assets/yield-mark.svg"), "utf8"), - ); - 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" }); + ) + 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`)); - assert.equal(runtime.name, npmPackage(target)); - assert.equal(runtime.version, "1.2.3"); - 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 runtime = await readJson(join(output, `npm/${target.id}/package.json`)) + assert.equal(runtime.name, npmPackage(target)) + assert.equal(runtime.version, "1.2.3") + 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}`); - 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"; + 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}`, - ); + ) - const rustRoot = join(output, `rust/runtime/${target.id}`); - const rustManifest = await readFile(join(rustRoot, "Cargo.toml"), "utf8"); - assert.match(rustManifest, new RegExp(`name = "${rustPackage(target)}"`)); - assert.match(rustManifest, /version = "1\.2\.3"/); - assert.match(rustManifest, /readme = "README\.md"/); - assert.doesNotMatch(rustManifest, /registry\s*=/); - assert.match(await readFile(join(rustRoot, "README.md"), "utf8"), /installed automatically by `yieldskill`/); - assert.match(await readFile(join(rustRoot, "LICENSE"), "utf8"), /MIT License/); - const rustRuntime = target.goos === "windows" ? "yskill.exe" : "yskill"; - assert.equal((await stat(join(rustRoot, "runtime", rustRuntime))).mode & 0o111, 0); + const rustRoot = join(output, `rust/runtime/${target.id}`) + const rustManifest = await readFile(join(rustRoot, "Cargo.toml"), "utf8") + assert.match(rustManifest, new RegExp(`name = "${rustPackage(target)}"`)) + assert.match(rustManifest, /version = "1\.2\.3"/) + assert.match(rustManifest, /readme = "README\.md"/) + assert.doesNotMatch(rustManifest, /registry\s*=/) + assert.match( + await readFile(join(rustRoot, "README.md"), "utf8"), + /installed automatically by `yieldskill`/, + ) + assert.match(await readFile(join(rustRoot, "LICENSE"), "utf8"), /MIT License/) + const rustRuntime = target.goos === "windows" ? "yskill.exe" : "yskill" + assert.equal((await stat(join(rustRoot, "runtime", rustRuntime))).mode & 0o111, 0) } - const rustMain = join(output, "rust/yieldskill"); - const rustMainManifest = await readFile(join(rustMain, "Cargo.toml"), "utf8"); - assert.match(rustMainManifest, /name = "yieldskill"/); - assert.match(rustMainManifest, /version = "1\.2\.3"/); - assert.doesNotMatch(rustMainManifest, /registry\s*=/); + const rustMain = join(output, "rust/yieldskill") + const rustMainManifest = await readFile(join(rustMain, "Cargo.toml"), "utf8") + assert.match(rustMainManifest, /name = "yieldskill"/) + assert.match(rustMainManifest, /version = "1\.2\.3"/) + assert.doesNotMatch(rustMainManifest, /registry\s*=/) for (const target of targets) { - assert.match(rustMainManifest, new RegExp(`${rustPackage(target)} = \\{ version = "=1\\.2\\.3" \\}`)); + assert.match( + rustMainManifest, + new RegExp(`${rustPackage(target)} = \\{ version = "=1\\.2\\.3" \\}`), + ) } - const rustReadme = await readFile(join(rustMain, "README.md"), "utf8"); - assert.match(rustReadme, /crates\.io\/crates\/yieldskill/); - assert.doesNotMatch(rustReadme, /npmjs\.com|pypi\.org/); - assert.match(await readFile(join(rustMain, "LICENSE"), "utf8"), /MIT License/); - await assert.rejects(access(join(output, "rust/.cargo/config.toml")), { code: "ENOENT" }); -}); + const rustReadme = await readFile(join(rustMain, "README.md"), "utf8") + assert.match(rustReadme, /crates\.io\/crates\/yieldskill/) + assert.doesNotMatch(rustReadme, /npmjs\.com|pypi\.org/) + assert.match(await readFile(join(rustMain, "LICENSE"), "utf8"), /MIT License/) + await assert.rejects(access(join(output, "rust/.cargo/config.toml")), { code: "ENOENT" }) +}) diff --git a/packaging/crates-release.mjs b/packaging/crates-release.mjs index 959246c..4ca1a13 100644 --- a/packaging/crates-release.mjs +++ b/packaging/crates-release.mjs @@ -1,148 +1,178 @@ #!/usr/bin/env node -import { createHash } from "node:crypto"; -import { readdir, readFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; -import process from "node:process"; -import { rustPackage, targets } from "./targets.mjs"; +import { createHash } from "node:crypto" +import { readdir, readFile } from "node:fs/promises" +import { join, resolve } from "node:path" +import process from "node:process" +import { rustPackage, targets } from "./targets.mjs" -export const crateNames = [...targets.map(rustPackage), "yieldskill"]; +export const crateNames = [...targets.map(rustPackage), "yieldskill"] export function indexPath(name) { - const normalized = name.toLowerCase(); - if (normalized.length === 1) return `1/${normalized}`; - if (normalized.length === 2) return `2/${normalized}`; - if (normalized.length === 3) return `3/${normalized[0]}/${normalized}`; - return `${normalized.slice(0, 2)}/${normalized.slice(2, 4)}/${normalized}`; + const normalized = name.toLowerCase() + if (normalized.length === 1) return `1/${normalized}` + if (normalized.length === 2) return `2/${normalized}` + if (normalized.length === 3) return `3/${normalized[0]}/${normalized}` + return `${normalized.slice(0, 2)}/${normalized.slice(2, 4)}/${normalized}` } export async function registryRecord(name, version, fetchImpl = fetch) { const response = await fetchImpl(`https://index.crates.io/${indexPath(name)}`, { headers: { "User-Agent": "operatorstack-yield-release/1" }, - }); - if (response.status === 404) return null; - if (!response.ok) throw new Error(`crates.io index returned HTTP ${response.status} for ${name}`); + }) + if (response.status === 404) return null + if (!response.ok) throw new Error(`crates.io index returned HTTP ${response.status} for ${name}`) for (const line of (await response.text()).split("\n").filter(Boolean)) { - const record = JSON.parse(line); - if (record.vers === version) return record; + const record = JSON.parse(line) + if (record.vers === version) return record } - return null; + return null } function parseArgs(argv) { - const values = {}; + const values = {} for (let index = 0; index < argv.length; index += 2) { - const key = argv[index]; - if (!key?.startsWith("--") || argv[index + 1] === undefined) throw new Error(`invalid argument ${key ?? ""}`); - values[key.slice(2)] = argv[index + 1]; + const key = argv[index] + if (!key?.startsWith("--") || argv[index + 1] === undefined) + throw new Error(`invalid argument ${key ?? ""}`) + values[key.slice(2)] = argv[index + 1] } - if (!/^\d+\.\d+\.\d+$/.test(values.version ?? "")) throw new Error("--version must be stable semver"); - return values; + if (!/^\d+\.\d+\.\d+$/.test(values.version ?? "")) + throw new Error("--version must be stable semver") + return values } function field(manifest, name) { - return manifest.match(new RegExp(`^${name}\\s*=\\s*"([^"]+)"`, "m"))?.[1] ?? ""; + return manifest.match(new RegExp(`^${name}\\s*=\\s*"([^"]+)"`, "m"))?.[1] ?? "" } export async function inspectRelease({ version, rust }) { - const root = resolve(rust); - const manifests = []; - for (const target of targets) manifests.push(join(root, "runtime", target.id, "Cargo.toml")); - manifests.push(join(root, "yieldskill", "Cargo.toml")); - const seen = []; + const root = resolve(rust) + const manifests = [] + for (const target of targets) manifests.push(join(root, "runtime", target.id, "Cargo.toml")) + manifests.push(join(root, "yieldskill", "Cargo.toml")) + const seen = [] for (const manifestPath of manifests) { - const manifest = await readFile(manifestPath, "utf8"); - const name = field(manifest, "name"); - if (!crateNames.includes(name)) throw new Error(`${manifestPath}: unexpected crate ${name}`); - if (field(manifest, "version") !== version) throw new Error(`${name}: expected version ${version}`); - if (field(manifest, "license") !== "MIT") throw new Error(`${name}: MIT license metadata is required`); - if (field(manifest, "repository") !== "https://github.com/operatorstack/yield") throw new Error(`${name}: canonical repository is required`); - if (/registry\s*=|get\.operatorstack\.systems/.test(manifest)) throw new Error(`${name}: private registry configuration is forbidden`); + const manifest = await readFile(manifestPath, "utf8") + const name = field(manifest, "name") + if (!crateNames.includes(name)) throw new Error(`${manifestPath}: unexpected crate ${name}`) + if (field(manifest, "version") !== version) + throw new Error(`${name}: expected version ${version}`) + if (field(manifest, "license") !== "MIT") + throw new Error(`${name}: MIT license metadata is required`) + if (field(manifest, "repository") !== "https://github.com/operatorstack/yield") + throw new Error(`${name}: canonical repository is required`) + if (/registry\s*=|get\.operatorstack\.systems/.test(manifest)) + throw new Error(`${name}: private registry configuration is forbidden`) if (name === "yieldskill") { for (const runtime of targets.map(rustPackage)) { - const escaped = runtime.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const dependency = new RegExp(`^${escaped}\\s*=\\s*\\{\\s*version\\s*=\\s*"=${version.replace(/\./g, "\\.")}"\\s*\\}$`, "m"); - if (!dependency.test(manifest)) throw new Error(`${name}: ${runtime} must be pinned to ${version} on crates.io`); + const escaped = runtime.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + const dependency = new RegExp( + `^${escaped}\\s*=\\s*\\{\\s*version\\s*=\\s*"=${version.replace(/\./g, "\\.")}"\\s*\\}$`, + "m", + ) + if (!dependency.test(manifest)) + throw new Error(`${name}: ${runtime} must be pinned to ${version} on crates.io`) } } - const readme = field(manifest, "readme"); - if (readme) await readFile(join(resolve(manifestPath, ".."), readme), "utf8"); - seen.push(name); + const readme = field(manifest, "readme") + if (readme) await readFile(join(resolve(manifestPath, ".."), readme), "utf8") + seen.push(name) } - if (new Set(seen).size !== crateNames.length) throw new Error("Rust release unit has duplicate or missing crates"); - return seen; + if (new Set(seen).size !== crateNames.length) + throw new Error("Rust release unit has duplicate or missing crates") + return seen } async function sha256(path) { - return createHash("sha256").update(await readFile(path)).digest("hex"); + return createHash("sha256") + .update(await readFile(path)) + .digest("hex") } function archiveName(name, version) { - return `${name}-${version}.crate`; + return `${name}-${version}.crate` } async function localArchives(directory, version) { - const names = new Set(await readdir(directory)); - const result = new Map(); + const names = new Set(await readdir(directory)) + const result = new Map() for (const name of crateNames) { - const file = archiveName(name, version); - if (!names.has(file)) throw new Error(`missing crate archive ${file}`); - result.set(name, resolve(directory, file)); + const file = archiveName(name, version) + if (!names.has(file)) throw new Error(`missing crate archive ${file}`) + result.set(name, resolve(directory, file)) } - if (names.size !== crateNames.length) throw new Error("crate archive directory contains unexpected files"); - return result; + if (names.size !== crateNames.length) + throw new Error("crate archive directory contains unexpected files") + return result } export async function status({ name, version, archive, fetchImpl = fetch }) { - if (!crateNames.includes(name)) throw new Error(`unexpected crate ${name}`); - const record = await registryRecord(name, version, fetchImpl); - if (!record) return "missing"; - const expected = await sha256(archive); - if (record.cksum !== expected) throw new Error(`${name}@${version}: published checksum does not match the release unit`); - return "matched"; + if (!crateNames.includes(name)) throw new Error(`unexpected crate ${name}`) + const record = await registryRecord(name, version, fetchImpl) + if (!record) return "missing" + const expected = await sha256(archive) + if (record.cksum !== expected) + throw new Error(`${name}@${version}: published checksum does not match the release unit`) + return "matched" } -export async function verifyRelease({ version, archives, attempts = 1, delayMs = 0, fetchImpl = fetch }) { - const local = await localArchives(resolve(archives), version); - let missing = []; +export async function verifyRelease({ + version, + archives, + attempts = 1, + delayMs = 0, + fetchImpl = fetch, +}) { + const local = await localArchives(resolve(archives), version) + let missing = [] for (let attempt = 1; attempt <= attempts; attempt += 1) { - missing = []; + missing = [] for (const [name, archive] of local) { - if (await status({ name, version, archive, fetchImpl }) === "missing") missing.push(name); + if ((await status({ name, version, archive, fetchImpl })) === "missing") missing.push(name) } - if (!missing.length) return crateNames; - if (attempt < attempts) await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs)); + if (!missing.length) return crateNames + if (attempt < attempts) await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs)) } - throw new Error(`crates.io is missing ${missing.join(", ")} at ${version}`); + throw new Error(`crates.io is missing ${missing.join(", ")} at ${version}`) } async function main() { - const [command, ...argv] = process.argv.slice(2); - const values = parseArgs(argv); + const [command, ...argv] = process.argv.slice(2) + const values = parseArgs(argv) if (command === "inspect") { - if (!values.rust) throw new Error("inspect requires --rust"); - const names = await inspectRelease({ version: values.version, rust: values.rust }); - console.log(`crates-release: inspected ${names.length} crates at ${values.version}`); - return; + if (!values.rust) throw new Error("inspect requires --rust") + const names = await inspectRelease({ version: values.version, rust: values.rust }) + console.log(`crates-release: inspected ${names.length} crates at ${values.version}`) + return } if (command === "status") { - if (!values.name || !values.archive) throw new Error("status requires --name and --archive"); - console.log(await status({ name: values.name, version: values.version, archive: resolve(values.archive) })); - return; + if (!values.name || !values.archive) throw new Error("status requires --name and --archive") + console.log( + await status({ + name: values.name, + version: values.version, + archive: resolve(values.archive), + }), + ) + return } if (command === "verify") { - if (!values.archives) throw new Error("verify requires --archives"); + if (!values.archives) throw new Error("verify requires --archives") const names = await verifyRelease({ version: values.version, archives: values.archives, attempts: Number(values.attempts ?? 1), delayMs: Number(values["delay-ms"] ?? 0), - }); - console.log(`crates-release: verified ${names.length} crates at ${values.version}`); - return; + }) + console.log(`crates-release: verified ${names.length} crates at ${values.version}`) + return } - throw new Error(`unknown command ${command ?? ""}`); + throw new Error(`unknown command ${command ?? ""}`) } if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { - main().catch((error) => { console.error(`crates-release: ${error.message}`); process.exit(1); }); + main().catch((error) => { + console.error(`crates-release: ${error.message}`) + process.exit(1) + }) } diff --git a/packaging/crates-release.test.mjs b/packaging/crates-release.test.mjs index 9583c2d..c85b1d3 100644 --- a/packaging/crates-release.test.mjs +++ b/packaging/crates-release.test.mjs @@ -1,58 +1,62 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { crateNames, indexPath, registryRecord, status, verifyRelease } from "./crates-release.mjs"; +import test from "node:test" +import assert from "node:assert/strict" +import { createHash } from "node:crypto" +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { crateNames, indexPath, registryRecord, status, verifyRelease } from "./crates-release.mjs" function response(statusCode, text = "") { - return { status: statusCode, ok: statusCode >= 200 && statusCode < 300, text: async () => text }; + return { status: statusCode, ok: statusCode >= 200 && statusCode < 300, text: async () => text } } test("maps public crate names to sparse index paths", () => { - assert.equal(indexPath("yieldskill"), "yi/el/yieldskill"); - assert.equal(indexPath("yieldskill-runtime-linux-amd64"), "yi/el/yieldskill-runtime-linux-amd64"); -}); + assert.equal(indexPath("yieldskill"), "yi/el/yieldskill") + assert.equal(indexPath("yieldskill-runtime-linux-amd64"), "yi/el/yieldskill-runtime-linux-amd64") +}) test("distinguishes an absent version from an absent crate", async () => { - assert.equal(await registryRecord("yieldskill", "1.2.3", async () => response(404)), null); - assert.equal(await registryRecord("yieldskill", "1.2.3", async () => response(200, '{"vers":"1.2.2"}\n')), null); -}); + assert.equal(await registryRecord("yieldskill", "1.2.3", async () => response(404)), null) + assert.equal( + await registryRecord("yieldskill", "1.2.3", async () => response(200, '{"vers":"1.2.2"}\n')), + null, + ) +}) test("refuses an immutable version with a different checksum", async (t) => { - const root = await mkdtemp(join(tmpdir(), "yield-crate-status-")); - t.after(() => rm(root, { recursive: true, force: true })); - const archive = join(root, "yieldskill-1.2.3.crate"); - await writeFile(archive, "release-unit"); + const root = await mkdtemp(join(tmpdir(), "yield-crate-status-")) + t.after(() => rm(root, { recursive: true, force: true })) + const archive = join(root, "yieldskill-1.2.3.crate") + await writeFile(archive, "release-unit") await assert.rejects( status({ name: "yieldskill", version: "1.2.3", archive, - fetchImpl: async () => response(200, `${JSON.stringify({ vers: "1.2.3", cksum: "0".repeat(64) })}\n`), + fetchImpl: async () => + response(200, `${JSON.stringify({ vers: "1.2.3", cksum: "0".repeat(64) })}\n`), }), /checksum does not match/, - ); -}); + ) +}) test("verifies the complete seven-crate release unit", async (t) => { - const root = await mkdtemp(join(tmpdir(), "yield-crates-verify-")); - t.after(() => rm(root, { recursive: true, force: true })); - await mkdir(root, { recursive: true }); - const checksums = new Map(); + const root = await mkdtemp(join(tmpdir(), "yield-crates-verify-")) + t.after(() => rm(root, { recursive: true, force: true })) + await mkdir(root, { recursive: true }) + const checksums = new Map() for (const name of crateNames) { - const body = `archive:${name}`; - await writeFile(join(root, `${name}-1.2.3.crate`), body); - checksums.set(name, createHash("sha256").update(body).digest("hex")); + const body = `archive:${name}` + await writeFile(join(root, `${name}-1.2.3.crate`), body) + checksums.set(name, createHash("sha256").update(body).digest("hex")) } const verified = await verifyRelease({ version: "1.2.3", archives: root, fetchImpl: async (url) => { - const name = url.slice(url.lastIndexOf("/") + 1); - return response(200, `${JSON.stringify({ vers: "1.2.3", cksum: checksums.get(name) })}\n`); + const name = url.slice(url.lastIndexOf("/") + 1) + return response(200, `${JSON.stringify({ vers: "1.2.3", cksum: checksums.get(name) })}\n`) }, - }); - assert.deepEqual(verified, crateNames); -}); + }) + assert.deepEqual(verified, crateNames) +}) diff --git a/packaging/create-yield.test.mjs b/packaging/create-yield.test.mjs index 1b5c4e6..c11ccc1 100644 --- a/packaging/create-yield.test.mjs +++ b/packaging/create-yield.test.mjs @@ -1,32 +1,50 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; +import test from "node:test" +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { tmpdir } from "node:os" test("npm initializer forwards bootstrap and user arguments to the matching CLI", async (t) => { - const root = await mkdtemp(join(tmpdir(), "create-yield-")); - t.after(() => rm(root, { recursive: true, force: true })); - const initializer = join(root, "node_modules/@operatorstack/create-yield"); - const sdk = join(root, "node_modules/@operatorstack/yield"); - await mkdir(join(initializer, "bin"), { recursive: true }); - await mkdir(join(sdk, "dist"), { recursive: true }); - await mkdir(join(sdk, "bin"), { recursive: true }); - await cp(join(import.meta.dirname, "create-yield/bin/create-yield.mjs"), join(initializer, "bin/create-yield.mjs")); - await writeFile(join(sdk, "package.json"), JSON.stringify({ - name: "@operatorstack/yield", - type: "module", - exports: { ".": "./dist/index.js" }, - })); - await writeFile(join(sdk, "dist/index.js"), "export {};\n"); - await writeFile(join(sdk, "bin/yskill.mjs"), "import { writeFileSync } from 'node:fs'; writeFileSync(process.env.RECEIPT, JSON.stringify(process.argv.slice(2)));\n"); - const receipt = join(root, "receipt.json"); - execFileSync(process.execPath, [join(initializer, "bin/create-yield.mjs"), "--root", root, "--dry-run"], { - cwd: root, - env: { ...process.env, RECEIPT: receipt }, - }); + const root = await mkdtemp(join(tmpdir(), "create-yield-")) + t.after(() => rm(root, { recursive: true, force: true })) + const initializer = join(root, "node_modules/@operatorstack/create-yield") + const sdk = join(root, "node_modules/@operatorstack/yield") + await mkdir(join(initializer, "bin"), { recursive: true }) + await mkdir(join(sdk, "dist"), { recursive: true }) + await mkdir(join(sdk, "bin"), { recursive: true }) + await cp( + join(import.meta.dirname, "create-yield/bin/create-yield.mjs"), + join(initializer, "bin/create-yield.mjs"), + ) + await writeFile( + join(sdk, "package.json"), + JSON.stringify({ + name: "@operatorstack/yield", + type: "module", + exports: { ".": "./dist/index.js" }, + }), + ) + await writeFile(join(sdk, "dist/index.js"), "export {};\n") + await writeFile( + join(sdk, "bin/yskill.mjs"), + "import { writeFileSync } from 'node:fs'; writeFileSync(process.env.RECEIPT, JSON.stringify(process.argv.slice(2)));\n", + ) + const receipt = join(root, "receipt.json") + execFileSync( + process.execPath, + [join(initializer, "bin/create-yield.mjs"), "--root", root, "--dry-run"], + { + cwd: root, + env: { ...process.env, RECEIPT: receipt }, + }, + ) assert.deepEqual(JSON.parse(await readFile(receipt, "utf8")), [ - "bootstrap", "--language", "typescript", "--root", root, "--dry-run", - ]); -}); + "bootstrap", + "--language", + "typescript", + "--root", + root, + "--dry-run", + ]) +}) diff --git a/packaging/create-yield/bin/create-yield.mjs b/packaging/create-yield/bin/create-yield.mjs index c58799e..dc21c5a 100644 --- a/packaging/create-yield/bin/create-yield.mjs +++ b/packaging/create-yield/bin/create-yield.mjs @@ -1,18 +1,22 @@ #!/usr/bin/env node -import { spawnSync } from "node:child_process"; -import { createRequire } from "node:module"; -import { dirname, resolve } from "node:path"; -import process from "node:process"; +import { spawnSync } from "node:child_process" +import { createRequire } from "node:module" +import { dirname, resolve } from "node:path" +import process from "node:process" -const require = createRequire(import.meta.url); -const sdkEntry = require.resolve("@operatorstack/yield"); -const cli = resolve(dirname(sdkEntry), "../bin/yskill.mjs"); -const result = spawnSync(process.execPath, [cli, "bootstrap", "--language", "typescript", ...process.argv.slice(2)], { - stdio: "inherit", -}); +const require = createRequire(import.meta.url) +const sdkEntry = require.resolve("@operatorstack/yield") +const cli = resolve(dirname(sdkEntry), "../bin/yskill.mjs") +const result = spawnSync( + process.execPath, + [cli, "bootstrap", "--language", "typescript", ...process.argv.slice(2)], + { + stdio: "inherit", + }, +) if (result.error) { - console.error(`create-yield: ${result.error.message}`); - process.exit(1); + console.error(`create-yield: ${result.error.message}`) + process.exit(1) } -process.exit(result.status ?? 1); +process.exit(result.status ?? 1) diff --git a/packaging/go-release.mjs b/packaging/go-release.mjs index 8b48d3f..0db9f22 100644 --- a/packaging/go-release.mjs +++ b/packaging/go-release.mjs @@ -1,36 +1,42 @@ #!/usr/bin/env node -import { execFile as execFileCallback } from "node:child_process"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import process from "node:process"; -import { promisify } from "node:util"; -import { pathToFileURL } from "node:url"; +import { execFile as execFileCallback } from "node:child_process" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import process from "node:process" +import { promisify } from "node:util" +import { pathToFileURL } from "node:url" -export const modulePath = "github.com/operatorstack/yield"; -const stableVersion = /^\d+\.\d+\.\d+$/; -const commit = /^[0-9a-f]{40}$/; -const execFile = promisify(execFileCallback); +export const modulePath = "github.com/operatorstack/yield" +const stableVersion = /^\d+\.\d+\.\d+$/ +const commit = /^[0-9a-f]{40}$/ +const execFile = promisify(execFileCallback) function goPlatform() { - const operatingSystem = process.platform === "win32" ? "windows" : process.platform; - const architecture = process.arch === "x64" ? "amd64" : process.arch; - return `${operatingSystem}/${architecture}`; + const operatingSystem = process.platform === "win32" ? "windows" : process.platform + const architecture = process.arch === "x64" ? "amd64" : process.arch + return `${operatingSystem}/${architecture}` } function expect(condition, message) { - if (!condition) throw new Error(message); + if (!condition) throw new Error(message) } export function validateModuleReceipt(receipt, { version, sourceSha }) { - expect(stableVersion.test(version), `invalid stable version ${version}`); - expect(commit.test(sourceSha), `invalid source SHA ${sourceSha}`); - expect(receipt?.Path === modulePath, `unexpected Go module ${receipt?.Path ?? "missing"}`); - expect(receipt?.Version === `v${version}`, `unexpected Go module version ${receipt?.Version ?? "missing"}`); - expect(receipt?.Origin?.VCS === "git", "Go module origin must use git"); - expect(receipt?.Origin?.URL === `https://${modulePath}`, `unexpected Go module origin ${receipt?.Origin?.URL ?? "missing"}`); - expect(receipt?.Origin?.Hash === sourceSha, "Go module source does not match the release tag"); - return receipt; + expect(stableVersion.test(version), `invalid stable version ${version}`) + expect(commit.test(sourceSha), `invalid source SHA ${sourceSha}`) + expect(receipt?.Path === modulePath, `unexpected Go module ${receipt?.Path ?? "missing"}`) + expect( + receipt?.Version === `v${version}`, + `unexpected Go module version ${receipt?.Version ?? "missing"}`, + ) + expect(receipt?.Origin?.VCS === "git", "Go module origin must use git") + expect( + receipt?.Origin?.URL === `https://${modulePath}`, + `unexpected Go module origin ${receipt?.Origin?.URL ?? "missing"}`, + ) + expect(receipt?.Origin?.Hash === sourceSha, "Go module source does not match the release tag") + return receipt } export async function verifyGoRelease({ @@ -41,60 +47,71 @@ export async function verifyGoRelease({ execImpl = execFile, delay = (milliseconds) => new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)), }) { - expect(Number.isInteger(attempts) && attempts > 0, "attempts must be a positive integer"); - const bin = await mkdtemp(join(tmpdir(), "yield-go-release-")); + expect(Number.isInteger(attempts) && attempts > 0, "attempts must be a positive integer") + const bin = await mkdtemp(join(tmpdir(), "yield-go-release-")) const environment = { ...process.env, GOBIN: bin, GOSUMDB: "sum.golang.org", GOPROXY: "https://proxy.golang.org", GOWORK: "off", - }; - let lastError; + } + let lastError try { for (let attempt = 1; attempt <= attempts; attempt += 1) { try { - const listed = await execImpl("go", ["list", "-m", "-json", `${modulePath}@v${version}`], { env: environment }); - validateModuleReceipt(JSON.parse(listed.stdout), { version, sourceSha }); - await execImpl("go", ["install", `${modulePath}/cmd/yskill@v${version}`], { env: environment }); - const executable = join(bin, process.platform === "win32" ? "yskill.exe" : "yskill"); - const installed = await execImpl(executable, ["--version"], { env: environment }); - expect(installed.stdout.trim() === `yskill ${version} ${goPlatform()}`, `unexpected yskill version: ${installed.stdout.trim()}`); - return { module: modulePath, version, sourceSha }; + const listed = await execImpl("go", ["list", "-m", "-json", `${modulePath}@v${version}`], { + env: environment, + }) + validateModuleReceipt(JSON.parse(listed.stdout), { version, sourceSha }) + await execImpl("go", ["install", `${modulePath}/cmd/yskill@v${version}`], { + env: environment, + }) + const executable = join(bin, process.platform === "win32" ? "yskill.exe" : "yskill") + const installed = await execImpl(executable, ["--version"], { env: environment }) + expect( + installed.stdout.trim() === `yskill ${version} ${goPlatform()}`, + `unexpected yskill version: ${installed.stdout.trim()}`, + ) + return { module: modulePath, version, sourceSha } } catch (error) { - lastError = error; - if (attempt < attempts) await delay(delayMs); + lastError = error + if (attempt < attempts) await delay(delayMs) } } } finally { - await rm(bin, { recursive: true, force: true }); + await rm(bin, { recursive: true, force: true }) } - throw lastError; + throw lastError } function parseArgs(argv) { - const values = {}; + const values = {} for (let index = 0; index < argv.length; index += 2) { - const key = argv[index]; - if (!key?.startsWith("--") || argv[index + 1] === undefined) throw new Error(`invalid argument ${key ?? ""}`); - values[key.slice(2)] = argv[index + 1]; + const key = argv[index] + if (!key?.startsWith("--") || argv[index + 1] === undefined) + throw new Error(`invalid argument ${key ?? ""}`) + values[key.slice(2)] = argv[index + 1] } - expect(stableVersion.test(values.version ?? ""), "--version must be stable semver"); - expect(commit.test(values["source-sha"] ?? ""), "--source-sha must be a full commit SHA"); - return values; + expect(stableVersion.test(values.version ?? ""), "--version must be stable semver") + expect(commit.test(values["source-sha"] ?? ""), "--source-sha must be a full commit SHA") + return values } async function main() { - const values = parseArgs(process.argv.slice(2)); + const values = parseArgs(process.argv.slice(2)) const result = await verifyGoRelease({ version: values.version, sourceSha: values["source-sha"], attempts: Number(values.attempts ?? "1"), delayMs: Number(values["delay-ms"] ?? "0"), - }); - process.stdout.write(`${JSON.stringify({ ...result, verified: true })}\n`); + }) + process.stdout.write(`${JSON.stringify({ ...result, verified: true })}\n`) } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { - main().catch((error) => { console.error(`go-release: ${error.message}`); process.exit(1); }); + main().catch((error) => { + console.error(`go-release: ${error.message}`) + process.exit(1) + }) } diff --git a/packaging/go-release.test.mjs b/packaging/go-release.test.mjs index 4748321..cdd2c7b 100644 --- a/packaging/go-release.test.mjs +++ b/packaging/go-release.test.mjs @@ -1,65 +1,71 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { modulePath, validateModuleReceipt, verifyGoRelease } from "./go-release.mjs"; +import test from "node:test" +import assert from "node:assert/strict" +import { modulePath, validateModuleReceipt, verifyGoRelease } from "./go-release.mjs" -const sourceSha = "a".repeat(40); -const goPlatform = `${process.platform === "win32" ? "windows" : process.platform}/${process.arch === "x64" ? "amd64" : process.arch}`; +const sourceSha = "a".repeat(40) +const goPlatform = `${process.platform === "win32" ? "windows" : process.platform}/${process.arch === "x64" ? "amd64" : process.arch}` function receipt(version = "1.2.3") { return { Path: modulePath, Version: `v${version}`, Origin: { VCS: "git", URL: `https://${modulePath}`, Hash: sourceSha }, - }; + } } test("binds the public Go module to its immutable tag source", () => { - assert.equal(validateModuleReceipt(receipt(), { version: "1.2.3", sourceSha }).Version, "v1.2.3"); + assert.equal(validateModuleReceipt(receipt(), { version: "1.2.3", sourceSha }).Version, "v1.2.3") assert.throws( - () => validateModuleReceipt({ ...receipt(), Origin: { ...receipt().Origin, Hash: "b".repeat(40) } }, { version: "1.2.3", sourceSha }), + () => + validateModuleReceipt( + { ...receipt(), Origin: { ...receipt().Origin, Hash: "b".repeat(40) } }, + { version: "1.2.3", sourceSha }, + ), /does not match the release tag/, - ); -}); + ) +}) test("verifies proxy discovery and a fresh command install", async () => { - const calls = []; + const calls = [] const result = await verifyGoRelease({ version: "1.2.3", sourceSha, execImpl: async (file, args, options) => { - calls.push({ file, args, options }); - if (args[0] === "list") return { stdout: JSON.stringify(receipt()) }; - if (file === "go") return { stdout: "" }; - return { stdout: `yskill 1.2.3 ${goPlatform}\n` }; + calls.push({ file, args, options }) + if (args[0] === "list") return { stdout: JSON.stringify(receipt()) } + if (file === "go") return { stdout: "" } + return { stdout: `yskill 1.2.3 ${goPlatform}\n` } }, - }); - assert.equal(result.module, modulePath); - assert.deepEqual(calls[0].args, ["list", "-m", "-json", `${modulePath}@v1.2.3`]); - assert.deepEqual(calls[1].args, ["install", `${modulePath}/cmd/yskill@v1.2.3`]); - assert.equal(calls[0].options.env.GOPROXY, "https://proxy.golang.org"); - assert.equal(calls[0].options.env.GOSUMDB, "sum.golang.org"); - assert.equal(calls[0].options.env.GOWORK, "off"); -}); + }) + assert.equal(result.module, modulePath) + assert.deepEqual(calls[0].args, ["list", "-m", "-json", `${modulePath}@v1.2.3`]) + assert.deepEqual(calls[1].args, ["install", `${modulePath}/cmd/yskill@v1.2.3`]) + assert.equal(calls[0].options.env.GOPROXY, "https://proxy.golang.org") + assert.equal(calls[0].options.env.GOSUMDB, "sum.golang.org") + assert.equal(calls[0].options.env.GOWORK, "off") +}) test("retries a proxy miss without accepting a partial release", async () => { - let lists = 0; - let delays = 0; + let lists = 0 + let delays = 0 await verifyGoRelease({ version: "1.2.3", sourceSha, attempts: 2, delayMs: 1, - delay: async () => { delays += 1; }, + delay: async () => { + delays += 1 + }, execImpl: async (file, args) => { if (args[0] === "list") { - lists += 1; - if (lists === 1) throw new Error("module not found"); - return { stdout: JSON.stringify(receipt()) }; + lists += 1 + if (lists === 1) throw new Error("module not found") + return { stdout: JSON.stringify(receipt()) } } - if (file === "go") return { stdout: "" }; - return { stdout: `yskill 1.2.3 ${goPlatform}\n` }; + if (file === "go") return { stdout: "" } + return { stdout: `yskill 1.2.3 ${goPlatform}\n` } }, - }); - assert.equal(lists, 2); - assert.equal(delays, 1); -}); + }) + assert.equal(lists, 2) + assert.equal(delays, 1) +}) diff --git a/packaging/pypi-release.mjs b/packaging/pypi-release.mjs index bc546e1..d34b5d5 100644 --- a/packaging/pypi-release.mjs +++ b/packaging/pypi-release.mjs @@ -1,119 +1,152 @@ #!/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"; +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+$/; +const stableVersion = /^\d+\.\d+\.\d+$/ function expect(condition, message) { - if (!condition) throw new Error(message); + if (!condition) throw new Error(message) } function parseArgs(argv) { - const [command, ...rest] = argv; - const values = {}; + 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]; + 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 }; + 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(); + 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"); + 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)) }))); + 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(); + 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}`); + 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)); + 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 ?? "" })); + }) + 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 }; + 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("")); + 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; + 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; + 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 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); + 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)); + const missing = compareRelease(local, await fetchPyPIRelease(version)) if (missing.length === 0) { - process.stdout.write(`${JSON.stringify({ version, verified: true, files: local.length })}\n`); - return; + 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 (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); }); + 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 index 865b930..a0ce72c 100644 --- a/packaging/pypi-release.test.mjs +++ b/packaging/pypi-release.test.mjs @@ -1,57 +1,72 @@ -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"; +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 }; + 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/); -}); + 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/); -}); + 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), []); -}); + 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, []); -}); + 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" }]); -}); + })) + assert.deepEqual(files, [{ filename: "a.whl", sha256: "abc" }]) +}) diff --git a/packaging/rust-launcher.rs b/packaging/rust-launcher.rs index 549e0cc..7b34f6f 100644 --- a/packaging/rust-launcher.rs +++ b/packaging/rust-launcher.rs @@ -4,60 +4,102 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; -#[cfg(all(target_os = "macos", target_arch = "x86_64"))] use yieldskill_runtime_darwin_amd64 as runtime; -#[cfg(all(target_os = "macos", target_arch = "aarch64"))] use yieldskill_runtime_darwin_arm64 as runtime; -#[cfg(all(target_os = "linux", target_arch = "x86_64"))] use yieldskill_runtime_linux_amd64 as runtime; -#[cfg(all(target_os = "linux", target_arch = "aarch64"))] use yieldskill_runtime_linux_arm64 as runtime; -#[cfg(all(target_os = "windows", target_arch = "x86_64"))] use yieldskill_runtime_windows_amd64 as runtime; -#[cfg(all(target_os = "windows", target_arch = "aarch64"))] use yieldskill_runtime_windows_arm64 as runtime; +#[cfg(all(target_os = "macos", target_arch = "x86_64"))] +use yieldskill_runtime_darwin_amd64 as runtime; +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +use yieldskill_runtime_darwin_arm64 as runtime; +#[cfg(all(target_os = "linux", target_arch = "x86_64"))] +use yieldskill_runtime_linux_amd64 as runtime; +#[cfg(all(target_os = "linux", target_arch = "aarch64"))] +use yieldskill_runtime_linux_arm64 as runtime; +#[cfg(all(target_os = "windows", target_arch = "x86_64"))] +use yieldskill_runtime_windows_amd64 as runtime; +#[cfg(all(target_os = "windows", target_arch = "aarch64"))] +use yieldskill_runtime_windows_arm64 as runtime; fn runtime_path() -> Result { - let root = std::env::var_os("YIELD_RUNTIME_CACHE").map(PathBuf::from) + let root = std::env::var_os("YIELD_RUNTIME_CACHE") + .map(PathBuf::from) .unwrap_or_else(|| std::env::temp_dir().join("yieldskill")); - let name = if cfg!(windows) { "yskill.exe" } else { "yskill" }; - let directory = root.join(env!("CARGO_PKG_VERSION")).join(std::env::consts::ARCH); + let name = if cfg!(windows) { + "yskill.exe" + } else { + "yskill" + }; + let directory = root + .join(env!("CARGO_PKG_VERSION")) + .join(std::env::consts::ARCH); let path = directory.join(name); - if verified(&path) { return Ok(path); } - fs::create_dir_all(&directory).map_err(|error| format!("could not create runtime cache: {error}"))?; + if verified(&path) { + return Ok(path); + } + fs::create_dir_all(&directory) + .map_err(|error| format!("could not create runtime cache: {error}"))?; let temporary = directory.join(format!(".{name}.{}.tmp", std::process::id())); - let mut file = OpenOptions::new().write(true).create_new(true).open(&temporary) + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) .map_err(|error| format!("could not stage packaged runtime: {error}"))?; - file.write_all(runtime::BYTES).and_then(|_| file.sync_all()) + file.write_all(runtime::BYTES) + .and_then(|_| file.sync_all()) .map_err(|error| format!("could not write packaged runtime: {error}"))?; - #[cfg(unix)] { + #[cfg(unix)] + { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&temporary, fs::Permissions::from_mode(0o755)) .map_err(|error| format!("could not mark packaged runtime executable: {error}"))?; } if let Err(error) = fs::rename(&temporary, &path) { let _ = fs::remove_file(&temporary); - if !verified(&path) { return Err(format!("could not install packaged runtime: {error}")); } + if !verified(&path) { + return Err(format!("could not install packaged runtime: {error}")); + } + } + if !verified(&path) { + return Err("packaged runtime checksum mismatch".to_string()); } - if !verified(&path) { return Err("packaged runtime checksum mismatch".to_string()); } Ok(path) } fn verified(path: &Path) -> bool { - fs::read(path).map(|bytes| hex::encode(Sha256::digest(bytes)) == runtime::SHA256).unwrap_or(false) + fs::read(path) + .map(|bytes| hex::encode(Sha256::digest(bytes)) == runtime::SHA256) + .unwrap_or(false) } fn main() { - let path = match runtime_path() { Ok(path) => path, Err(error) => { eprintln!("yskill: {error}"); std::process::exit(1); } }; + let path = match runtime_path() { + Ok(path) => path, + Err(error) => { + eprintln!("yskill: {error}"); + std::process::exit(1); + } + }; let args: Vec<_> = std::env::args_os().skip(1).collect(); let mut command = Command::new(path); command.args(args); - if std::env::var_os("YIELD_LANGUAGE").is_none() { command.env("YIELD_LANGUAGE", "rust"); } - if let Ok(launcher) = std::env::current_exe() { command.env("YIELD_LAUNCHER_PATH", launcher); } - #[cfg(unix)] { + if std::env::var_os("YIELD_LANGUAGE").is_none() { + command.env("YIELD_LANGUAGE", "rust"); + } + if let Ok(launcher) = std::env::current_exe() { + command.env("YIELD_LAUNCHER_PATH", launcher); + } + #[cfg(unix)] + { use std::os::unix::process::CommandExt; let error = command.exec(); eprintln!("yskill: could not start packaged runtime: {error}"); std::process::exit(1) } - #[cfg(windows)] { + #[cfg(windows)] + { match command.status() { Ok(status) => std::process::exit(status.code().unwrap_or(1)), - Err(error) => { eprintln!("yskill: could not start packaged runtime: {error}"); std::process::exit(1); } + Err(error) => { + eprintln!("yskill: could not start packaged runtime: {error}"); + std::process::exit(1); + } } } } diff --git a/packaging/rust-launcher.test.mjs b/packaging/rust-launcher.test.mjs index 87611b5..c6abbc3 100644 --- a/packaging/rust-launcher.test.mjs +++ b/packaging/rust-launcher.test.mjs @@ -1,9 +1,9 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import test from "node:test" +import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" test("Rust wrapper passes its repository-local path to the runtime", async () => { - const source = await readFile(new URL("./rust-launcher.rs", import.meta.url), "utf8"); - assert.match(source, /current_exe\(\)/); - assert.match(source, /YIELD_LAUNCHER_PATH/); -}); + const source = await readFile(new URL("./rust-launcher.rs", import.meta.url), "utf8") + assert.match(source, /current_exe\(\)/) + assert.match(source, /YIELD_LAUNCHER_PATH/) +}) diff --git a/packaging/targets.mjs b/packaging/targets.mjs index d4104ae..15b11a0 100644 --- a/packaging/targets.mjs +++ b/packaging/targets.mjs @@ -1,20 +1,74 @@ export const targets = [ - { id: "darwin-amd64", goos: "darwin", goarch: "amd64", nodeOs: "darwin", nodeCpu: "x64", pythonTag: "macosx_11_0_x86_64", rustOs: "macos", rustArch: "x86_64" }, - { id: "darwin-arm64", goos: "darwin", goarch: "arm64", nodeOs: "darwin", nodeCpu: "arm64", pythonTag: "macosx_11_0_arm64", rustOs: "macos", rustArch: "aarch64" }, - { id: "linux-amd64", goos: "linux", goarch: "amd64", nodeOs: "linux", nodeCpu: "x64", pythonTag: "manylinux_2_17_x86_64", rustOs: "linux", rustArch: "x86_64" }, - { id: "linux-arm64", goos: "linux", goarch: "arm64", nodeOs: "linux", nodeCpu: "arm64", pythonTag: "manylinux_2_17_aarch64", rustOs: "linux", rustArch: "aarch64" }, - { id: "windows-amd64", goos: "windows", goarch: "amd64", nodeOs: "win32", nodeCpu: "x64", pythonTag: "win_amd64", rustOs: "windows", rustArch: "x86_64" }, - { id: "windows-arm64", goos: "windows", goarch: "arm64", nodeOs: "win32", nodeCpu: "arm64", pythonTag: "win_arm64", rustOs: "windows", rustArch: "aarch64" }, -]; + { + id: "darwin-amd64", + goos: "darwin", + goarch: "amd64", + nodeOs: "darwin", + nodeCpu: "x64", + pythonTag: "macosx_11_0_x86_64", + rustOs: "macos", + rustArch: "x86_64", + }, + { + id: "darwin-arm64", + goos: "darwin", + goarch: "arm64", + nodeOs: "darwin", + nodeCpu: "arm64", + pythonTag: "macosx_11_0_arm64", + rustOs: "macos", + rustArch: "aarch64", + }, + { + id: "linux-amd64", + goos: "linux", + goarch: "amd64", + nodeOs: "linux", + nodeCpu: "x64", + pythonTag: "manylinux_2_17_x86_64", + rustOs: "linux", + rustArch: "x86_64", + }, + { + id: "linux-arm64", + goos: "linux", + goarch: "arm64", + nodeOs: "linux", + nodeCpu: "arm64", + pythonTag: "manylinux_2_17_aarch64", + rustOs: "linux", + rustArch: "aarch64", + }, + { + id: "windows-amd64", + goos: "windows", + goarch: "amd64", + nodeOs: "win32", + nodeCpu: "x64", + pythonTag: "win_amd64", + rustOs: "windows", + rustArch: "x86_64", + }, + { + id: "windows-arm64", + goos: "windows", + goarch: "arm64", + nodeOs: "win32", + nodeCpu: "arm64", + pythonTag: "win_arm64", + rustOs: "windows", + rustArch: "aarch64", + }, +] export function binaryName(target) { - return `yskill-${target.goos}-${target.goarch}${target.goos === "windows" ? ".exe" : ""}`; + return `yskill-${target.goos}-${target.goarch}${target.goos === "windows" ? ".exe" : ""}` } export function npmPackage(target) { - return `@operatorstack/yield-${target.id}`; + return `@operatorstack/yield-${target.id}` } export function rustPackage(target) { - return `yieldskill-runtime-${target.id}`; + return `yieldskill-runtime-${target.id}` } diff --git a/packaging/verify-registry-history.mjs b/packaging/verify-registry-history.mjs index 48ef79c..c4f478b 100644 --- a/packaging/verify-registry-history.mjs +++ b/packaging/verify-registry-history.mjs @@ -1,94 +1,118 @@ #!/usr/bin/env node -import process from "node:process"; -import { npmPackage, rustPackage, targets } from "./targets.mjs"; +import process from "node:process" +import { npmPackage, rustPackage, targets } from "./targets.mjs" function parseArgs(argv) { - const values = {}; + const values = {} for (let index = 0; index < argv.length; index += 2) { - values[argv[index]?.replace(/^--/, "")] = argv[index + 1]; + values[argv[index]?.replace(/^--/, "")] = argv[index + 1] } - const versions = (values.versions ?? "").split(",").filter(Boolean); + const versions = (values.versions ?? "").split(",").filter(Boolean) if (!versions.length || versions.some((version) => !/^\d+\.\d+\.\d+$/.test(version))) { - throw new Error("--versions must be a comma-separated semver list"); + throw new Error("--versions must be a comma-separated semver list") } return { versions: [...new Set(versions)], base: (values.base ?? "https://get.operatorstack.systems").replace(/\/$/, ""), - }; + } } async function fetchText(fetchImpl, url) { - const response = await fetchImpl(url); - if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`); - return response.text(); + const response = await fetchImpl(url) + if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`) + return response.text() } async function fetchJSON(fetchImpl, url) { - return JSON.parse(await fetchText(fetchImpl, url)); + return JSON.parse(await fetchText(fetchImpl, url)) } function npmPath(name) { - return name.replace("/", "%2F"); + return name.replace("/", "%2F") } function cargoPath(name) { - const lower = name.toLowerCase(); - if (lower.length === 1) return `1/${lower}`; - if (lower.length === 2) return `2/${lower}`; - if (lower.length === 3) return `3/${lower[0]}/${lower}`; - return `${lower.slice(0, 2)}/${lower.slice(2, 4)}/${lower}`; + const lower = name.toLowerCase() + if (lower.length === 1) return `1/${lower}` + if (lower.length === 2) return `2/${lower}` + if (lower.length === 3) return `3/${lower[0]}/${lower}` + return `${lower.slice(0, 2)}/${lower.slice(2, 4)}/${lower}` } function cargoVersions(text, expectedName) { - const versions = new Set(); + const versions = new Set() for (const line of text.split("\n").filter(Boolean)) { - const record = JSON.parse(line); - if (record.name !== expectedName) throw new Error(`Cargo index for ${expectedName} contains ${record.name}`); - if (/^\d+\.\d+\.\d+$/.test(record.vers)) versions.add(record.vers); + const record = JSON.parse(line) + if (record.name !== expectedName) + throw new Error(`Cargo index for ${expectedName} contains ${record.name}`) + if (/^\d+\.\d+\.\d+$/.test(record.vers)) versions.add(record.vers) } - return versions; + return versions } export async function verifyRegistryHistory({ versions, base, fetchImpl = fetch }) { - const missing = []; - const npmNames = ["@operatorstack/yield", "@operatorstack/create-yield", ...targets.map(npmPackage)]; + const missing = [] + const npmNames = [ + "@operatorstack/yield", + "@operatorstack/create-yield", + ...targets.map(npmPackage), + ] for (const name of npmNames) { - const packument = await fetchJSON(fetchImpl, `${base}/npm/${npmPath(name)}`); - const available = new Set(Object.keys(packument.versions ?? {})); + const packument = await fetchJSON(fetchImpl, `${base}/npm/${npmPath(name)}`) + const available = new Set(Object.keys(packument.versions ?? {})) for (const version of versions) { - if (!available.has(version)) missing.push(`npm ${name}@${version}`); + if (!available.has(version)) missing.push(`npm ${name}@${version}`) } } - const pythonIndex = await fetchText(fetchImpl, `${base}/pip/simple/yieldskill/`); + const pythonIndex = await fetchText(fetchImpl, `${base}/pip/simple/yieldskill/`) for (const version of versions) { for (const target of targets) { - const filename = `yieldskill-${version}-py3-none-${target.pythonTag}.whl`; - if (!pythonIndex.includes(filename)) missing.push(`Python ${filename}`); + const filename = `yieldskill-${version}-py3-none-${target.pythonTag}.whl` + if (!pythonIndex.includes(filename)) missing.push(`Python ${filename}`) } } - const goVersions = new Set((await fetchText(fetchImpl, `${base}/go/github.com/operatorstack/yield/@v/list`)).split(/\s+/).filter(Boolean)); + const goVersions = new Set( + (await fetchText(fetchImpl, `${base}/go/github.com/operatorstack/yield/@v/list`)) + .split(/\s+/) + .filter(Boolean), + ) for (const version of versions) { - if (!goVersions.has(`v${version}`)) missing.push(`Go github.com/operatorstack/yield@v${version}`); + if (!goVersions.has(`v${version}`)) + missing.push(`Go github.com/operatorstack/yield@v${version}`) } - const rustNames = ["yieldskill", ...targets.map(rustPackage)]; + const rustNames = ["yieldskill", ...targets.map(rustPackage)] for (const name of rustNames) { - const available = cargoVersions(await fetchText(fetchImpl, `${base}/cargo/index/${cargoPath(name)}`), name); + const available = cargoVersions( + await fetchText(fetchImpl, `${base}/cargo/index/${cargoPath(name)}`), + name, + ) for (const version of versions) { - if (!available.has(version)) missing.push(`Cargo ${name}@${version}`); + if (!available.has(version)) missing.push(`Cargo ${name}@${version}`) } } if (missing.length) { - throw new Error(`registry history is incomplete:\n - ${missing.join("\n - ")}`); + throw new Error(`registry history is incomplete:\n - ${missing.join("\n - ")}`) + } + return { + versions, + languages: ["typescript", "python", "go", "rust"], + targets: targets.map((target) => target.id), } - return { versions, languages: ["typescript", "python", "go", "rust"], targets: targets.map((target) => target.id) }; } if (process.argv[1] && import.meta.filename === process.argv[1]) { verifyRegistryHistory({ ...parseArgs(process.argv.slice(2)) }) - .then((result) => console.log(`verified ${result.versions.length} release versions across 4 SDKs and ${result.targets.length} targets`)) - .catch((error) => { console.error(`verify-registry-history: ${error.message}`); process.exit(1); }); + .then((result) => + console.log( + `verified ${result.versions.length} release versions across 4 SDKs and ${result.targets.length} targets`, + ), + ) + .catch((error) => { + console.error(`verify-registry-history: ${error.message}`) + process.exit(1) + }) } diff --git a/packaging/verify-registry-history.test.mjs b/packaging/verify-registry-history.test.mjs index d310c2b..fd5f9af 100644 --- a/packaging/verify-registry-history.test.mjs +++ b/packaging/verify-registry-history.test.mjs @@ -1,56 +1,79 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { npmPackage, rustPackage, targets } from "./targets.mjs"; -import { verifyRegistryHistory } from "./verify-registry-history.mjs"; +import test from "node:test" +import assert from "node:assert/strict" +import { npmPackage, rustPackage, targets } from "./targets.mjs" +import { verifyRegistryHistory } from "./verify-registry-history.mjs" -const versions = ["0.1.23", "0.1.24"]; +const versions = ["0.1.23", "0.1.24"] function response(body, status = 200) { - return { ok: status >= 200 && status < 300, status, text: async () => body }; + return { ok: status >= 200 && status < 300, status, text: async () => body } } function registry({ omit = "" } = {}) { return async (url) => { if (url.includes("/npm/")) { - const name = decodeURIComponent(url.split("/npm/")[1]); - const present = Object.fromEntries(versions.filter((version) => `${name}@${version}` !== omit).map((version) => [version, {}])); - return response(JSON.stringify({ name, versions: present })); + const name = decodeURIComponent(url.split("/npm/")[1]) + const present = Object.fromEntries( + versions.filter((version) => `${name}@${version}` !== omit).map((version) => [version, {}]), + ) + return response(JSON.stringify({ name, versions: present })) } if (url.includes("/pip/simple/yieldskill/")) { - const files = versions.flatMap((version) => targets.map((target) => `yieldskill-${version}-py3-none-${target.pythonTag}.whl`)); - return response(files.filter((file) => `Python ${file}` !== omit).join("\n")); + const files = versions.flatMap((version) => + targets.map((target) => `yieldskill-${version}-py3-none-${target.pythonTag}.whl`), + ) + return response(files.filter((file) => `Python ${file}` !== omit).join("\n")) } if (url.includes("/go/github.com/operatorstack/yield/@v/list")) { - return response(versions.filter((version) => `Go github.com/operatorstack/yield@v${version}` !== omit).map((version) => `v${version}`).join("\n")); + return response( + versions + .filter((version) => `Go github.com/operatorstack/yield@v${version}` !== omit) + .map((version) => `v${version}`) + .join("\n"), + ) } if (url.includes("/cargo/index/")) { - const name = url.split("/").at(-1); - const records = versions.filter((version) => `Cargo ${name}@${version}` !== omit).map((vers) => JSON.stringify({ name, vers })); - return response(records.join("\n") + "\n"); + const name = url.split("/").at(-1) + const records = versions + .filter((version) => `Cargo ${name}@${version}` !== omit) + .map((vers) => JSON.stringify({ name, vers })) + return response(records.join("\n") + "\n") } - return response("not found", 404); - }; + return response("not found", 404) + } } test("verifies every SDK, platform package, wheel, and runtime crate", async () => { - const result = await verifyRegistryHistory({ versions, base: "https://registry.test", fetchImpl: registry() }); - assert.deepEqual(result.versions, versions); - assert.deepEqual(result.languages, ["typescript", "python", "go", "rust"]); - assert.equal(result.targets.length, 6); -}); + const result = await verifyRegistryHistory({ + versions, + base: "https://registry.test", + fetchImpl: registry(), + }) + assert.deepEqual(result.versions, versions) + assert.deepEqual(result.languages, ["typescript", "python", "go", "rust"]) + assert.equal(result.targets.length, 6) +}) test("reports a missing historical version instead of accepting latest", async () => { - const missing = `Cargo ${rustPackage(targets[0])}@${versions[0]}`; + const missing = `Cargo ${rustPackage(targets[0])}@${versions[0]}` await assert.rejects( - verifyRegistryHistory({ versions, base: "https://registry.test", fetchImpl: registry({ omit: missing }) }), + verifyRegistryHistory({ + versions, + base: "https://registry.test", + fetchImpl: registry({ omit: missing }), + }), new RegExp(missing), - ); -}); + ) +}) test("reports missing platform packages, not only public package names", async () => { - const missing = `${npmPackage(targets[1])}@${versions[1]}`; + const missing = `${npmPackage(targets[1])}@${versions[1]}` await assert.rejects( - verifyRegistryHistory({ versions, base: "https://registry.test", fetchImpl: registry({ omit: missing }) }), + verifyRegistryHistory({ + versions, + base: "https://registry.test", + fetchImpl: registry({ omit: missing }), + }), new RegExp(missing.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), - ); -}); + ) +}) diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..71ee50a --- /dev/null +++ b/ruff.toml @@ -0,0 +1,7 @@ +line-length = 100 +target-version = "py310" + +[format] +indent-style = "space" +line-ending = "lf" +quote-style = "double" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..a46af26 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.97.1" +components = ["rustfmt"] +profile = "minimal" diff --git a/scripts/audit-repository-controls.mjs b/scripts/audit-repository-controls.mjs index 5a9430b..a49609c 100644 --- a/scripts/audit-repository-controls.mjs +++ b/scripts/audit-repository-controls.mjs @@ -1,65 +1,117 @@ #!/usr/bin/env node -import process from "node:process"; -import { resolve } from "node:path"; -import { pathToFileURL } from "node:url"; +import process from "node:process" +import { resolve } from "node:path" +import { pathToFileURL } from "node:url" const requiredChecks = [ "Go and agent registration (ubuntu-latest)", "Go and agent registration (macos-latest)", "Go and agent registration (windows-latest)", "Release authority and full validation", -]; +] function expect(condition, message) { - if (!condition) throw new Error(message); + if (!condition) throw new Error(message) } -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"); - expect(protection.enforce_admins?.enabled === true, "administrators must not bypass main protection"); - expect(protection.required_status_checks?.strict === true, "required checks must run against current main"); - expect(protection.allow_force_pushes?.enabled === false, "main must reject force pushes"); - expect(protection.allow_deletions?.enabled === false, "main must reject deletion"); - const contexts = new Set(protection.required_status_checks?.contexts ?? []); - 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"); - 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 }; +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", + ) + expect( + protection.enforce_admins?.enabled === true, + "administrators must not bypass main protection", + ) + expect( + protection.required_status_checks?.strict === true, + "required checks must run against current main", + ) + expect(protection.allow_force_pushes?.enabled === false, "main must reject force pushes") + expect(protection.allow_deletions?.enabled === false, "main must reject deletion") + const contexts = new Set(protection.required_status_checks?.contexts ?? []) + 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", + ) + 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) { - const repository = process.env.GITHUB_REPOSITORY ?? "operatorstack/yield"; + const repository = process.env.GITHUB_REPOSITORY ?? "operatorstack/yield" const response = await fetch(`https://api.github.com/repos/${repository}/${path}`, { headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${process.env.GH_TOKEN}`, "X-GitHub-Api-Version": "2022-11-28", }, - }); - if (!response.ok) throw new Error(`${path}: GitHub HTTP ${response.status}`); - return response.json(); + }) + if (!response.ok) throw new Error(`${path}: GitHub HTTP ${response.status}`) + return response.json() } async function main() { - expect(process.env.GH_TOKEN, "GH_TOKEN is required"); + expect(process.env.GH_TOKEN, "GH_TOKEN is required") 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, pypiEnvironment }); - console.log(`repository-controls: ${result.requiredChecks} required checks, immutable tag ruleset ${result.immutableTagRuleset}, and ${result.pypiEnvironment} 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) { - main().catch((error) => { console.error(`repository-controls: ${error.message}`); process.exit(1); }); + main().catch((error) => { + console.error(`repository-controls: ${error.message}`) + process.exit(1) + }) } diff --git a/scripts/audit-repository-controls.test.mjs b/scripts/audit-repository-controls.test.mjs index fd496ab..ff27068 100644 --- a/scripts/audit-repository-controls.test.mjs +++ b/scripts/audit-repository-controls.test.mjs @@ -1,6 +1,6 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { auditRepositoryControls } from "./audit-repository-controls.mjs"; +import test from "node:test" +import assert from "node:assert/strict" +import { auditRepositoryControls } from "./audit-repository-controls.mjs" function controls(overrides = {}) { return { @@ -8,40 +8,69 @@ function controls(overrides = {}) { actions: { enabled: true, sha_pinning_required: true }, protection: { enforce_admins: { enabled: true }, - required_status_checks: { strict: true, contexts: [ - "Go and agent registration (ubuntu-latest)", - "Go and agent registration (macos-latest)", - "Go and agent registration (windows-latest)", - "Release authority and full validation", - ] }, + required_status_checks: { + strict: true, + contexts: [ + "Go and agent registration (ubuntu-latest)", + "Go and agent registration (macos-latest)", + "Go and agent registration (windows-latest)", + "Release authority and full validation", + ], + }, allow_force_pushes: { enabled: false }, allow_deletions: { enabled: false }, }, - rulesets: [{ id: 1, name: "Immutable Yield release tags", target: "tag", enforcement: "active" }], + 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" }], - }], + protection_rules: [ + { + type: "required_reviewers", + prevent_self_review: false, + reviewers: [{ reviewer: { login: "bigboateng" }, type: "User" }], + }, + ], }, ...overrides, - }; + } } test("accepts the complete repository control surface", () => { - assert.equal(auditRepositoryControls(controls()).requiredChecks, 4); -}); + assert.equal(auditRepositoryControls(controls()).requiredChecks, 4) +}) 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/); -}); + 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/); -}); + 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 0cb351b..2a6c0da 100644 --- a/scripts/check-release-control.mjs +++ b/scripts/check-release-control.mjs @@ -1,125 +1,313 @@ #!/usr/bin/env node -import { readdir, readFile, stat } from "node:fs/promises"; -import { resolve } from "node:path"; -import process from "node:process"; -import { pathToFileURL } from "node:url"; -import { parse } from "yaml"; +import { readdir, readFile, stat } from "node:fs/promises" +import { resolve } from "node:path" +import process from "node:process" +import { pathToFileURL } from "node:url" +import { parse } from "yaml" -const SHA_REF = /^[^\s@]+@[0-9a-f]{40}$/; +const SHA_REF = /^[^\s@]+@[0-9a-f]{40}$/ function expect(condition, message) { - if (!condition) throw new Error(message); + if (!condition) throw new Error(message) } function usesIn(value, found = []) { if (Array.isArray(value)) { - for (const item of value) usesIn(item, found); + for (const item of value) usesIn(item, found) } else if (value && typeof value === "object") { for (const [key, item] of Object.entries(value)) { - if (key === "uses" && typeof item === "string") found.push(item); - else usesIn(item, found); + if (key === "uses" && typeof item === "string") found.push(item) + else usesIn(item, found) } } - return found; + return found } async function exists(path) { - return stat(path).then(() => true, () => false); + return stat(path).then( + () => true, + () => false, + ) } export async function checkReleaseControl(root = resolve(import.meta.dirname, "..")) { - const workflowDir = resolve(root, ".github/workflows"); - const names = (await readdir(workflowDir)).filter((name) => name.endsWith(".yml") || name.endsWith(".yaml")); - const workflows = {}; - const raw = {}; + const workflowDir = resolve(root, ".github/workflows") + const names = (await readdir(workflowDir)).filter( + (name) => name.endsWith(".yml") || name.endsWith(".yaml"), + ) + const workflows = {} + const raw = {} for (const name of names) { - raw[name] = await readFile(resolve(workflowDir, name), "utf8"); - workflows[name] = parse(raw[name]); + raw[name] = await readFile(resolve(workflowDir, name), "utf8") + workflows[name] = parse(raw[name]) for (const action of usesIn(workflows[name])) { - expect(action.startsWith("./") || SHA_REF.test(action), `${name}: action is not pinned to a full commit SHA: ${action}`); + expect( + action.startsWith("./") || SHA_REF.test(action), + `${name}: action is not pinned to a full commit SHA: ${action}`, + ) } } - expect(!(await exists(resolve(root, "UPSTREAM.json"))), "UPSTREAM.json must be removed after graduation"); - expect(!names.includes("sync-upstream.yml"), "projection sync workflow must be removed after graduation"); + expect( + !(await exists(resolve(root, "UPSTREAM.json"))), + "UPSTREAM.json must be removed after graduation", + ) + expect( + !names.includes("sync-upstream.yml"), + "projection sync workflow must be removed after graduation", + ) - const verify = workflows["verify.yml"]; - 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"); + const verify = workflows["verify.yml"] + const validationJobs = [ + "format", + "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", + ) expect( - JSON.stringify([...(verify.jobs?.validate?.needs ?? [])].sort()) === JSON.stringify([...validationJobs].sort()), + JSON.stringify([...(verify.jobs?.validate?.needs ?? [])].sort()) === + JSON.stringify([...validationJobs].sort()), "the final validation gate must depend on every visible validation job", - ); - expect(verify.jobs?.validate?.name === "Release authority and full validation", "the protected validation context must remain stable"); + ) + expect( + verify.jobs?.validate?.name === "Release authority and full validation", + "the protected validation context must remain stable", + ) - const release = workflows["release.yml"]; - expect(release, "release.yml is required"); - expect(JSON.stringify(Object.keys(release.on ?? {}).sort()) === JSON.stringify(["workflow_dispatch"]), "stable release must be dispatch-only"); - expect(release.permissions?.contents === "read", "release planning must be read-only"); - expect(release.jobs?.release?.permissions?.contents === "write" && release.jobs?.release?.permissions?.actions === "write", "the controller needs tag and publisher-dispatch authority"); - expect(release.jobs?.release?.environment === "release-control", "release authorization must use the protected release-control environment"); - expect(raw["release.yml"].includes('repos/$GITHUB_REPOSITORY/git/refs'), "release controller must create tags with its scoped GitHub token"); - expect(!raw["release.yml"].includes('git push origin "refs/tags/$TAG"'), "release controller must not push tags without explicit authentication"); - 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 release = workflows["release.yml"] + expect(release, "release.yml is required") + expect( + JSON.stringify(Object.keys(release.on ?? {}).sort()) === JSON.stringify(["workflow_dispatch"]), + "stable release must be dispatch-only", + ) + expect(release.permissions?.contents === "read", "release planning must be read-only") + expect( + release.jobs?.release?.permissions?.contents === "write" && + release.jobs?.release?.permissions?.actions === "write", + "the controller needs tag and publisher-dispatch authority", + ) + expect( + release.jobs?.release?.environment === "release-control", + "release authorization must use the protected release-control environment", + ) + expect( + raw["release.yml"].includes("repos/$GITHUB_REPOSITORY/git/refs"), + "release controller must create tags with its scoped GitHub token", + ) + expect( + !raw["release.yml"].includes('git push origin "refs/tags/$TAG"'), + "release controller must not push tags without explicit authentication", + ) + 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 publisher = workflows["npm-publish.yml"]; - expect(publisher, "npm-publish.yml is required because both registry trust policies bind to this workflow identity"); - 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_dispatch?.inputs?.version?.required === true, "stable packages must require an exact version dispatch"); - expect(publisher.jobs?.npm?.permissions?.contents === "read" && publisher.jobs?.npm?.permissions?.["id-token"] === "write", "npm publisher must use read-only source plus OIDC"); - expect(publisher.jobs?.pypi?.permissions?.contents === "read" && publisher.jobs?.pypi?.permissions?.["id-token"] === "write", "PyPI publisher must use read-only source plus OIDC"); - expect(publisher.jobs?.crates?.permissions?.contents === "read" && publisher.jobs?.crates?.permissions?.["id-token"] === "write", "crates.io publisher must use read-only source plus OIDC"); - expect(publisher.jobs?.pypi?.environment === "pypi-production", "stable PyPI publishing must use the protected pypi-production environment"); - expect(publisher.jobs?.crates?.environment === "crates-production", "stable crates.io publishing must use the protected crates-production environment"); - 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"); - expect(raw["npm-publish.yml"].includes("@operatorstack/create-yield@${VERSION}"), "npm trusted publishing must include the initializer package"); - expect(raw["npm-publish.yml"].indexOf("Publish SDK and CLI") < raw["npm-publish.yml"].indexOf("Publish npm initializer"), "the SDK package must publish before the initializer"); - expect(raw["npm-publish.yml"].indexOf("Publish npm initializer") < raw["npm-publish.yml"].indexOf("Verify complete npm release unit"), "the initializer must publish before release-unit verification"); - expect(raw["npm-publish.yml"].includes('npm create "@operatorstack/yield@${VERSION}"'), "the exact published initializer must pass a dry-run smoke test"); - expect(raw["npm-publish.yml"].includes("pypa/gh-action-pypi-publish@"), "PyPI publishing must use the trusted-publishing action"); - expect(raw["npm-publish.yml"].includes("rust-lang/crates-io-auth-action@"), "crates.io publishing must use the trusted-publishing action"); - expect(raw["npm-publish.yml"].includes("chmod 0644 dist/packages/rust/runtime/*/runtime/*"), "Rust archives must normalize embedded runtime modes before artifact transport"); - expect(raw["npm-publish.yml"].includes("SOURCE_DATE_EPOCH"), "Python wheels must bind timestamps to the immutable source revision"); - expect(raw["npm-publish.yml"].includes("name: crates-${{ needs.resolve.outputs.version }}-${{ needs.resolve.outputs.source_sha }}"), "the crates publisher must upload an exact post-dependency receipt"); - expect(raw["npm-publish.yml"].indexOf("rust/runtime/*") < raw["npm-publish.yml"].indexOf("rust/yieldskill"), "Rust runtime crates must publish before the SDK crate"); - expect(!raw["npm-publish.yml"].includes("skip-existing"), "PyPI retries must verify hashes instead of blindly skipping existing files"); + const publisher = workflows["npm-publish.yml"] + expect( + publisher, + "npm-publish.yml is required because both registry trust policies bind to this workflow identity", + ) + 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_dispatch?.inputs?.version?.required === true, + "stable packages must require an exact version dispatch", + ) + expect( + publisher.jobs?.npm?.permissions?.contents === "read" && + publisher.jobs?.npm?.permissions?.["id-token"] === "write", + "npm publisher must use read-only source plus OIDC", + ) + expect( + publisher.jobs?.pypi?.permissions?.contents === "read" && + publisher.jobs?.pypi?.permissions?.["id-token"] === "write", + "PyPI publisher must use read-only source plus OIDC", + ) + expect( + publisher.jobs?.crates?.permissions?.contents === "read" && + publisher.jobs?.crates?.permissions?.["id-token"] === "write", + "crates.io publisher must use read-only source plus OIDC", + ) + expect( + publisher.jobs?.pypi?.environment === "pypi-production", + "stable PyPI publishing must use the protected pypi-production environment", + ) + expect( + publisher.jobs?.crates?.environment === "crates-production", + "stable crates.io publishing must use the protected crates-production environment", + ) + 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", + ) + expect( + raw["npm-publish.yml"].includes("@operatorstack/create-yield@${VERSION}"), + "npm trusted publishing must include the initializer package", + ) + expect( + raw["npm-publish.yml"].indexOf("Publish SDK and CLI") < + raw["npm-publish.yml"].indexOf("Publish npm initializer"), + "the SDK package must publish before the initializer", + ) + expect( + raw["npm-publish.yml"].indexOf("Publish npm initializer") < + raw["npm-publish.yml"].indexOf("Verify complete npm release unit"), + "the initializer must publish before release-unit verification", + ) + expect( + raw["npm-publish.yml"].includes('npm create "@operatorstack/yield@${VERSION}"'), + "the exact published initializer must pass a dry-run smoke test", + ) + expect( + raw["npm-publish.yml"].includes("pypa/gh-action-pypi-publish@"), + "PyPI publishing must use the trusted-publishing action", + ) + expect( + raw["npm-publish.yml"].includes("rust-lang/crates-io-auth-action@"), + "crates.io publishing must use the trusted-publishing action", + ) + expect( + raw["npm-publish.yml"].includes("chmod 0644 dist/packages/rust/runtime/*/runtime/*"), + "Rust archives must normalize embedded runtime modes before artifact transport", + ) + expect( + raw["npm-publish.yml"].includes("SOURCE_DATE_EPOCH"), + "Python wheels must bind timestamps to the immutable source revision", + ) + expect( + raw["npm-publish.yml"].includes( + "name: crates-${{ needs.resolve.outputs.version }}-${{ needs.resolve.outputs.source_sha }}", + ), + "the crates publisher must upload an exact post-dependency receipt", + ) + expect( + raw["npm-publish.yml"].indexOf("rust/runtime/*") < + raw["npm-publish.yml"].indexOf("rust/yieldskill"), + "Rust runtime crates must publish before the SDK crate", + ) + expect( + !raw["npm-publish.yml"].includes("skip-existing"), + "PyPI retries must verify hashes instead of blindly skipping existing files", + ) - const finalizer = workflows["release-finalize.yml"]; - expect(finalizer?.permissions?.actions === "read" && finalizer.permissions?.contents === "read", "finalizer preflight must be read-only"); + 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?.actions === "read" && finalizer.jobs?.finalize?.permissions?.contents === "write", + 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("github.event.workflow_run.event == 'workflow_dispatch'"), "automatic finalization must ignore canary publisher runs"); - expect(raw["release-finalize.yml"].includes('select(.event == "workflow_dispatch")'), "finalization must select only stable publisher receipts"); - expect(raw["release-finalize.yml"].includes("--draft=false"), "only the receipt finalizer may publish the GitHub release"); - expect(raw["release-finalize.yml"].includes("npm-publish.yml"), "finalization must bind the combined publisher receipt"); - expect(raw["release-finalize.yml"].includes("pypi-release.mjs verify"), "finalization must verify the PyPI wheel hashes"); - expect(raw["release-finalize.yml"].includes("crates-release.mjs verify"), "finalization must verify the crates.io package hashes"); - expect(raw["release-finalize.yml"].includes("go-release.mjs"), "finalization must verify the public Go module and command install"); - expect(raw["release-finalize.yml"].includes("--source-sha \"$SOURCE_SHA\""), "Go finalization must bind the module to the release source"); - expect(raw["release-finalize.yml"].includes("--name \"crates-${version}-${SOURCE_SHA}\""), "finalization must consume the publisher-produced crates receipt"); - expect(raw["release.yml"].includes("gh workflow run npm-publish.yml"), "the release controller must dispatch the trusted-publishing event after tagging"); - expect(!Object.values(raw).some((text) => text.includes("CRATES_BOOTSTRAP_TOKEN")), "crates.io publishing must not use a bootstrap token"); + ) + expect( + finalizer.jobs?.finalize?.needs === "resolve", + "finalization must follow read-only tag resolution", + ) + expect( + raw["release-finalize.yml"].includes("github.event.workflow_run.event == 'workflow_dispatch'"), + "automatic finalization must ignore canary publisher runs", + ) + expect( + raw["release-finalize.yml"].includes('select(.event == "workflow_dispatch")'), + "finalization must select only stable publisher receipts", + ) + expect( + raw["release-finalize.yml"].includes("--draft=false"), + "only the receipt finalizer may publish the GitHub release", + ) + expect( + raw["release-finalize.yml"].includes("npm-publish.yml"), + "finalization must bind the combined publisher receipt", + ) + expect( + raw["release-finalize.yml"].includes("pypi-release.mjs verify"), + "finalization must verify the PyPI wheel hashes", + ) + expect( + raw["release-finalize.yml"].includes("crates-release.mjs verify"), + "finalization must verify the crates.io package hashes", + ) + expect( + raw["release-finalize.yml"].includes("go-release.mjs"), + "finalization must verify the public Go module and command install", + ) + expect( + raw["release-finalize.yml"].includes('--source-sha "$SOURCE_SHA"'), + "Go finalization must bind the module to the release source", + ) + expect( + raw["release-finalize.yml"].includes('--name "crates-${version}-${SOURCE_SHA}"'), + "finalization must consume the publisher-produced crates receipt", + ) + expect( + raw["release.yml"].includes("gh workflow run npm-publish.yml"), + "the release controller must dispatch the trusted-publishing event after tagging", + ) + expect( + !Object.values(raw).some((text) => text.includes("CRATES_BOOTSTRAP_TOKEN")), + "crates.io publishing must not use a bootstrap token", + ) for (const [name, text] of Object.entries(raw)) { - expect(!/NPM_TOKEN|NODE_AUTH_TOKEN|PYPI_TOKEN|secrets\.(npm|pypi)|password:/i.test(text), `${name}: long-lived registry credentials are forbidden`); + 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 }; + return { + workflows: names.length, + externalActionsPinned: names + .flatMap((name) => usesIn(workflows[name])) + .filter((ref) => !ref.startsWith("./")).length, + } } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { checkReleaseControl() - .then((result) => console.log(`release-control: ${result.workflows} workflows and ${result.externalActionsPinned} pinned action references verified`)) - .catch((error) => { console.error(`release-control: ${error.message}`); process.exit(1); }); + .then((result) => + console.log( + `release-control: ${result.workflows} workflows and ${result.externalActionsPinned} pinned action references verified`, + ), + ) + .catch((error) => { + console.error(`release-control: ${error.message}`) + process.exit(1) + }) } diff --git a/scripts/check-release-control.test.mjs b/scripts/check-release-control.test.mjs index 64d3b83..6a6c428 100644 --- a/scripts/check-release-control.test.mjs +++ b/scripts/check-release-control.test.mjs @@ -1,9 +1,9 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { checkReleaseControl } from "./check-release-control.mjs"; +import test from "node:test" +import assert from "node:assert/strict" +import { checkReleaseControl } from "./check-release-control.mjs" test("repository workflows preserve the supervised release boundary", async () => { - const result = await checkReleaseControl(); - assert.ok(result.workflows >= 5); - assert.ok(result.externalActionsPinned > 0); -}); + const result = await checkReleaseControl() + assert.ok(result.workflows >= 5) + assert.ok(result.externalActionsPinned > 0) +}) diff --git a/scripts/format.mjs b/scripts/format.mjs new file mode 100644 index 0000000..f418565 --- /dev/null +++ b/scripts/format.mjs @@ -0,0 +1,109 @@ +import { execFileSync, spawnSync } from "node:child_process" + +const mode = process.argv[2] +if (mode !== "--check" && mode !== "--write") { + throw new Error("usage: node scripts/format.mjs --check|--write") +} + +const tracked = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], { + encoding: "utf8", +}) + .split("\n") + .filter(Boolean) + +const filesWith = (...extensions) => + tracked.filter((path) => extensions.some((extension) => path.endsWith(extension))) + +// These source bytes are bound to the committed 12-session agent receipt. +// Format them only when that evaluation is intentionally rerun. +const byteBoundAgentSource = (path) => + path.startsWith("evals/agent/") || + path.startsWith("internal/") || + path === "sdk/typescript/src/index.ts" + +const generatedLibrarySource = (path) => + /examples\/library\/(go|python|rust|typescript)\//.test(path) + +const prettierFiles = filesWith( + ".cjs", + ".js", + ".json", + ".md", + ".mdx", + ".mjs", + ".ts", + ".tsx", + ".yaml", + ".yml", +) +const pythonFiles = filesWith(".py", ".pyi").filter( + (path) => !byteBoundAgentSource(path) && !generatedLibrarySource(path), +) +const goFiles = filesWith(".go").filter((path) => !generatedLibrarySource(path)) +const rustFiles = filesWith(".rs").filter((path) => !generatedLibrarySource(path)) +const shellFiles = filesWith(".bash", ".sh") +const tomlFiles = filesWith(".toml") + +function run(label, command, args) { + process.stdout.write(`${label}\n`) + const result = spawnSync(command, args, { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + stdio: ["inherit", "pipe", "pipe"], + }) + if (result.stdout) process.stdout.write(result.stdout) + if (result.stderr) process.stderr.write(result.stderr) + if (result.error) { + throw new Error(`${label} could not start: ${result.error.message}`) + } + if (result.status !== 0) { + throw new Error(`${label} failed with exit code ${result.status}`) + } +} + +run("Prettier", "npm", [ + "exec", + "--", + "prettier", + mode === "--check" ? "--check" : "--write", + ...prettierFiles, +]) +run("Ruff", "uvx", [ + "--from", + "ruff==0.16.2", + "ruff", + "format", + ...(mode === "--check" ? ["--check"] : []), + ...pythonFiles, +]) + +if (mode === "--check") { + const result = execFileSync("gofmt", ["-l", ...goFiles], { encoding: "utf8" }) + if (result.trim()) throw new Error(`gofmt found unformatted files:\n${result}`) + process.stdout.write("gofmt\n") +} else { + run("gofmt", "gofmt", ["-w", ...goFiles]) +} + +run("rustfmt", "rustfmt", [ + "--edition", + "2021", + ...(mode === "--check" ? ["--check"] : []), + ...rustFiles, +]) +run("shfmt", "go", [ + "run", + "mvdan.cc/sh/v3/cmd/shfmt@v3.13.1", + mode === "--check" ? "-d" : "-w", + ...shellFiles, +]) +run("Taplo", "npm", [ + "exec", + "--", + "taplo", + "format", + ...(mode === "--check" ? ["--check"] : []), + ...tomlFiles, +]) + +process.stdout.write(`format ${mode === "--check" ? "check" : "write"} passed\n`) diff --git a/scripts/prepare-selfhost.mjs b/scripts/prepare-selfhost.mjs index e1fd5c7..1f45405 100644 --- a/scripts/prepare-selfhost.mjs +++ b/scripts/prepare-selfhost.mjs @@ -1,10 +1,10 @@ #!/usr/bin/env node -import { chmod, realpath, stat } from "node:fs/promises"; -import { createRequire } from "node:module"; -import { resolve, sep } from "node:path"; +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 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"], @@ -12,14 +12,18 @@ const platforms = new Map([ ["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`); +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/scripts/readme.test.mjs b/scripts/readme.test.mjs index e56853c..c815014 100644 --- a/scripts/readme.test.mjs +++ b/scripts/readme.test.mjs @@ -1,316 +1,330 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import test from "node:test" +import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" +import { resolve } from "node:path" -const root = resolve(import.meta.dirname, ".."); +const root = resolve(import.meta.dirname, "..") async function text(path) { - return readFile(resolve(root, path), "utf8"); + return readFile(resolve(root, path), "utf8") } test("README release example matches the tested TypeScript source", async () => { const [readme, source] = await Promise.all([ text("README.md"), text("examples/release-checklist/main.ts"), - ]); + ]) const readmeMatch = readme.match( /\s*```typescript\n([\s\S]*?)\n```\s*/, - ); - assert.ok(readmeMatch, "README release example markers are missing"); + ) + assert.ok(readmeMatch, "README release example markers are missing") - const sourceMatch = source.match( - /\/\/ README_EXAMPLE_START\n([\s\S]*?)\n\/\/ README_EXAMPLE_END/, - ); - assert.ok(sourceMatch, "TypeScript release example markers are missing"); + const sourceMatch = source.match(/\/\/ README_EXAMPLE_START\n([\s\S]*?)\n\/\/ README_EXAMPLE_END/) + assert.ok(sourceMatch, "TypeScript release example markers are missing") const readmeProgram = readmeMatch[1] - .replace(/^import \{ defineSkill \} from "@operatorstack\/yield";\n+/, "") - .trim(); - assert.equal(readmeProgram, sourceMatch[1].trim()); -}); + .replace(/^import \{ defineSkill \} from "@operatorstack\/yield";?\n+/, "") + .trim() + assert.equal(readmeProgram, sourceMatch[1].trim()) +}) test("Python README example matches the tested environment doctor", async () => { const [readme, source] = await Promise.all([ text("sdk/python/README.md"), text("examples/env-doctor/main.py"), - ]); + ]) const readmeMatch = readme.match( /\s*```python\n([\s\S]*?)\n```\s*/, - ); - assert.ok(readmeMatch, "Python README example markers are missing"); + ) + assert.ok(readmeMatch, "Python README example markers are missing") - const sourceMatch = source.match( - /# README_EXAMPLE_START\n([\s\S]*?)\n# README_EXAMPLE_END/, - ); - assert.ok(sourceMatch, "Python source example markers are missing"); + const sourceMatch = source.match(/# README_EXAMPLE_START\n([\s\S]*?)\n# README_EXAMPLE_END/) + assert.ok(sourceMatch, "Python source example markers are missing") - const readmeProgram = readmeMatch[1] - .replace(/^from yieldskill import define_skill\n+/, "") - .trim(); - assert.equal(readmeProgram, sourceMatch[1].trim()); -}); + const readmeProgram = readmeMatch[1].replace(/^from yieldskill import define_skill\n+/, "").trim() + assert.equal(readmeProgram, sourceMatch[1].trim()) +}) test("Rust README example matches the tested data migration", async () => { const [readme, source, fixture] = await Promise.all([ text("sdk/rust/README.md"), text("examples/data-migration/src/main.rs"), text("examples/data-migration/fixtures/responses.json"), - ]); + ]) const readmeMatch = readme.match( /\s*```rust\n([\s\S]*?)\n```\s*/, - ); - assert.ok(readmeMatch, "Rust README example markers are missing"); + ) + assert.ok(readmeMatch, "Rust README example markers are missing") - const sourceMatch = source.match( - /\/\/ README_EXAMPLE_START\n([\s\S]*?)\n\/\/ README_EXAMPLE_END/, - ); - assert.ok(sourceMatch, "Rust source example markers are missing"); - assert.equal(readmeMatch[1].trim(), sourceMatch[1].trim()); + const sourceMatch = source.match(/\/\/ README_EXAMPLE_START\n([\s\S]*?)\n\/\/ README_EXAMPLE_END/) + assert.ok(sourceMatch, "Rust source example markers are missing") + assert.equal(readmeMatch[1].trim(), sourceMatch[1].trim()) const fixtureMatch = readme.match( /\s*```json\n([\s\S]*?)\n```\s*/, - ); - assert.ok(fixtureMatch, "Rust README fixture markers are missing"); - assert.deepEqual(JSON.parse(fixtureMatch[1]), JSON.parse(fixture)); -}); + ) + assert.ok(fixtureMatch, "Rust README fixture markers are missing") + assert.deepEqual(JSON.parse(fixtureMatch[1]), JSON.parse(fixture)) +}) test("Go README example matches the tested investigation workflow", async () => { const [readme, source, fixture] = await Promise.all([ text("sdk/yield/README.md"), text("examples/investigate/main.go"), text("examples/investigate/fixtures/responses.json"), - ]); + ]) const readmeMatch = readme.match( /\s*```go\n([\s\S]*?)\n```\s*/, - ); - assert.ok(readmeMatch, "Go README example markers are missing"); - const sourceMatch = source.match(/^(package main[\s\S]*)$/m); - assert.ok(sourceMatch, "Go source program is missing"); - assert.equal(readmeMatch[1].trim(), sourceMatch[1].trim()); + ) + assert.ok(readmeMatch, "Go README example markers are missing") + const sourceMatch = source.match(/^(package main[\s\S]*)$/m) + assert.ok(sourceMatch, "Go source program is missing") + assert.equal(readmeMatch[1].trim(), sourceMatch[1].trim()) const fixtureMatch = readme.match( /\s*```json\n([\s\S]*?)\n```\s*/, - ); - assert.ok(fixtureMatch, "Go README fixture markers are missing"); - assert.deepEqual(JSON.parse(fixtureMatch[1]), JSON.parse(fixture)); -}); + ) + assert.ok(fixtureMatch, "Go README fixture markers are missing") + assert.deepEqual(JSON.parse(fixtureMatch[1]), JSON.parse(fixture)) +}) test("Go README presents a public five-step workflow", async () => { - const readme = await text("sdk/yield/README.md"); + const readme = await text("sdk/yield/README.md") const headings = [ "### 1. Install Yield", "### 2. Create the workflow", "### 3. Test the workflow", "### 4. Register the skill", "### 5. Run the skill", - ]; - let previous = -1; + ] + let previous = -1 for (const heading of headings) { - const current = readme.indexOf(heading); - assert.ok(current > previous, `${heading} is missing or out of order`); - previous = current; + const current = readme.indexOf(heading) + assert.ok(current > previous, `${heading} is missing or out of order`) + previous = current } - assert.match(readme, /go install github\.com\/operatorstack\/yield\/cmd\/yskill@latest/); - assert.match(readme, /yskill init skills\/investigate/); - assert.match(readme, /yskill doctor skills\/investigate --test/); - assert.match(readme, /yskill register skills\/investigate/); - assert.match(readme, /^\/investigate$/m); - assert.match(readme, /https:\/\/pkg\.go\.dev\/github\.com\/operatorstack\/yield\/sdk\/yield/); - assert.match(readme, /https:\/\/proxy\.golang\.org/); - assert.doesNotMatch(readme, /npmjs\.com|pypi\.org|crates\.io/); - assert.doesNotMatch(readme, /(?:href|src)="(?!https:\/\/)/); -}); + assert.match(readme, /go install github\.com\/operatorstack\/yield\/cmd\/yskill@latest/) + assert.match(readme, /yskill init skills\/investigate/) + assert.match(readme, /yskill doctor skills\/investigate --test/) + assert.match(readme, /yskill register skills\/investigate/) + assert.match(readme, /^\/investigate$/m) + assert.match(readme, /https:\/\/pkg\.go\.dev\/github\.com\/operatorstack\/yield\/sdk\/yield/) + assert.match(readme, /https:\/\/proxy\.golang\.org/) + assert.doesNotMatch(readme, /npmjs\.com|pypi\.org|crates\.io/) + assert.doesNotMatch(readme, /(?:href|src)="(?!https:\/\/)/) +}) test("Rust README presents a public five-step workflow", async () => { - const readme = await text("sdk/rust/README.md"); + const readme = await text("sdk/rust/README.md") const headings = [ "### 1. Install Yield", "### 2. Create the workflow", "### 3. Test the workflow", "### 4. Register the skill", "### 5. Run the skill", - ]; + ] - let previous = -1; + let previous = -1 for (const heading of headings) { - const current = readme.indexOf(heading); - assert.ok(current > previous, `${heading} is missing or out of order`); - previous = current; + const current = readme.indexOf(heading) + assert.ok(current > previous, `${heading} is missing or out of order`) + previous = current } - assert.match(readme, /cargo install yieldskill --locked/); - assert.match(readme, /yskill init skills\/data-migration/); - assert.match(readme, /yskill doctor skills\/data-migration --test/); - assert.match(readme, /yskill register skills\/data-migration/); - assert.match(readme, /^\/data-migration$/m); - assert.match(readme, /https:\/\/crates\.io\/crates\/yieldskill/); - assert.match(readme, /https:\/\/docs\.rs\/yieldskill/); - assert.doesNotMatch(readme, /get\.operatorstack\.systems\/cargo|npmjs\.com|pypi\.org/); - assert.doesNotMatch(readme, /(?:href|src)="(?!https:\/\/)/); -}); + assert.match(readme, /cargo install yieldskill --locked/) + assert.match(readme, /yskill init skills\/data-migration/) + assert.match(readme, /yskill doctor skills\/data-migration --test/) + assert.match(readme, /yskill register skills\/data-migration/) + assert.match(readme, /^\/data-migration$/m) + assert.match(readme, /https:\/\/crates\.io\/crates\/yieldskill/) + assert.match(readme, /https:\/\/docs\.rs\/yieldskill/) + assert.doesNotMatch(readme, /get\.operatorstack\.systems\/cargo|npmjs\.com|pypi\.org/) + assert.doesNotMatch(readme, /(?:href|src)="(?!https:\/\/)/) +}) test("Python README presents a public five-step workflow", async () => { - const readme = await text("sdk/python/README.md"); + const readme = await text("sdk/python/README.md") const headings = [ "### 1. Install Yield", "### 2. Create the workflow", "### 3. Test the workflow", "### 4. Register the skill", "### 5. Run the skill", - ]; + ] - let previous = -1; + let previous = -1 for (const heading of headings) { - const current = readme.indexOf(heading); - assert.ok(current > previous, `${heading} is missing or out of order`); - previous = current; + const current = readme.indexOf(heading) + assert.ok(current > previous, `${heading} is missing or out of order`) + previous = current } - assert.match(readme, /python -m pip install yieldskill/); - assert.match(readme, /python -m yieldskill init skills\/env-doctor/); - assert.match(readme, /python -m yieldskill doctor skills\/env-doctor --test/); - assert.match(readme, /python -m yieldskill register skills\/env-doctor/); - assert.match(readme, /^\/env-doctor$/m); - assert.match(readme, /https:\/\/github\.com\/operatorstack\/yield\/blob\/main\/docs\/reference\/cli\.md/); - assert.doesNotMatch(readme, /get\.operatorstack\.systems\/pip/); - assert.doesNotMatch(readme, /npmjs\.com|npm version/); - assert.doesNotMatch(readme, /(?:href|src)="(?!https:\/\/)/); -}); + assert.match(readme, /python -m pip install yieldskill/) + assert.match(readme, /python -m yieldskill init skills\/env-doctor/) + assert.match(readme, /python -m yieldskill doctor skills\/env-doctor --test/) + assert.match(readme, /python -m yieldskill register skills\/env-doctor/) + assert.match(readme, /^\/env-doctor$/m) + assert.match( + readme, + /https:\/\/github\.com\/operatorstack\/yield\/blob\/main\/docs\/reference\/cli\.md/, + ) + assert.doesNotMatch(readme, /get\.operatorstack\.systems\/pip/) + assert.doesNotMatch(readme, /npmjs\.com|npm version/) + assert.doesNotMatch(readme, /(?:href|src)="(?!https:\/\/)/) +}) test("README agent claims match the pinned registry", async () => { const [readme, registryText] = await Promise.all([ text("README.md"), text("cmd/yskill/registry/agents.json"), - ]); - const registry = JSON.parse(registryText); - const verified = registry.agents.filter((agent) => agent.tier === "verified"); - const registryBacked = registry.agents.filter((agent) => agent.tier === "registry"); - const normalized = readme.replace(/\s+/g, " "); - - assert.deepEqual( - verified.map((agent) => agent.id).sort(), - ["claude-code", "codex", "cursor"], - ); - assert.match(normalized, /Verified with Cursor, Codex, and Claude Code\./); + ]) + const registry = JSON.parse(registryText) + const verified = registry.agents.filter((agent) => agent.tier === "verified") + const registryBacked = registry.agents.filter((agent) => agent.tier === "registry") + const normalized = readme.replace(/\s+/g, " ") + + assert.deepEqual(verified.map((agent) => agent.id).sort(), ["claude-code", "codex", "cursor"]) + assert.match(normalized, /Verified with Cursor, Codex, and Claude Code\./) assert.match( normalized, - new RegExp(`Registry-backed project paths are available for ${registryBacked.length} more coding agents\\.`), - ); - assert.doesNotMatch(readme, /Agent Plugins and Yield/); -}); + new RegExp( + `Registry-backed project paths are available for ${registryBacked.length} more coding agents\\.`, + ), + ) + assert.doesNotMatch(readme, /Agent Plugins and Yield/) +}) test("README presents the workflow as five ordered steps", async () => { - const readme = await text("README.md"); + const readme = await text("README.md") const headings = [ "### 1. Install Yield", "### 2. Create the workflow", "### 3. Test the workflow", "### 4. Register the skill", "### 5. Run the skill", - ]; + ] - let previous = -1; + let previous = -1 for (const heading of headings) { - const current = readme.indexOf(heading); - assert.ok(current > previous, `${heading} is missing or out of order`); - previous = current; + const current = readme.indexOf(heading) + assert.ok(current > previous, `${heading} is missing or out of order`) + previous = current } - assert.match(readme, /npm exec -- yskill doctor skills\/release --test/); - assert.match(readme, /npm exec -- yskill register skills\/release/); - assert.match(readme, /Registration is the discovery step\./); - assert.match(readme, /^\/release$/m); - assert.match(readme, /Use the release skill to publish this package\./); -}); + assert.match(readme, /npm exec -- yskill doctor skills\/release --test/) + assert.match(readme, /npm exec -- yskill register skills\/release/) + assert.match(readme, /Registration is the discovery step\./) + assert.match(readme, /^\/release$/m) + assert.match(readme, /Use the release skill to publish this package\./) +}) test("README adapter paths match every verified agent", async () => { const [readme, registryText] = await Promise.all([ text("README.md"), text("cmd/yskill/registry/agents.json"), - ]); - const registry = JSON.parse(registryText); + ]) + const registry = JSON.parse(registryText) for (const agent of registry.agents.filter((entry) => entry.tier === "verified")) { - const adapter = `${agent.project_dir}/release/SKILL.md`; - assert.match(readme, new RegExp(adapter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + const adapter = `${agent.project_dir}/release/SKILL.md` + assert.match(readme, new RegExp(adapter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))) } - assert.match(readme, /--agent cursor,codex,claude-code/); - assert.match(readme, /If all three are selected/); -}); + assert.match(readme, /--agent cursor,codex,claude-code/) + assert.match(readme, /If all three are selected/) +}) test("README uses the borderless Yield mark", async () => { - const [readme, mark] = await Promise.all([ - text("README.md"), - text("assets/yield-mark.svg"), - ]); + const [readme, mark] = await Promise.all([text("README.md"), text("assets/yield-mark.svg")]) assert.match( readme, /Yield/, - ); - assert.doesNotMatch(readme, /yield-mark\.svg" width="96" height=/); - assert.doesNotMatch(readme, /apple-touch-icon\.png/); - assert.match(mark, /width="96" height="96" viewBox="0 0 60 60"/); - assert.match(mark, //); - assert.doesNotMatch(mark, //, + ) + assert.doesNotMatch(mark, / { - const [readme, pythonReadme, rustReadme, goReadme, docsIndex, quickstart, agentSetup] = await Promise.all([ - text("README.md"), - text("sdk/python/README.md"), - text("sdk/rust/README.md"), - text("sdk/yield/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(readme, /href="https:\/\/crates\.io\/crates\/yieldskill">crates\.io<\/a>/); - assert.match(readme, /href="https:\/\/pkg\.go\.dev\/github\.com\/operatorstack\/yield\/sdk\/yield">pkg\.go\.dev<\/a>/); - assert.match(pythonReadme, /python -m pip install yieldskill/); - assert.match(pythonReadme, /https:\/\/pypi\.org\/project\/yieldskill\//); - assert.doesNotMatch(pythonReadme, /get\.operatorstack\.systems\/pip/); - assert.match(rustReadme, /cargo install yieldskill --locked/); - assert.doesNotMatch(rustReadme, /get\.operatorstack\.systems\/cargo/); - assert.match(goReadme, /go install github\.com\/operatorstack\/yield\/cmd\/yskill@latest/); - assert.match(docsIndex, /\[public documentation\]\(https:\/\/yield\.operatorstack\.systems\/docs\/\)/); + const [readme, pythonReadme, rustReadme, goReadme, docsIndex, quickstart, agentSetup] = + await Promise.all([ + text("README.md"), + text("sdk/python/README.md"), + text("sdk/rust/README.md"), + text("sdk/yield/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(readme, /href="https:\/\/crates\.io\/crates\/yieldskill">crates\.io<\/a>/) + assert.match( + readme, + /href="https:\/\/pkg\.go\.dev\/github\.com\/operatorstack\/yield\/sdk\/yield">pkg\.go\.dev<\/a>/, + ) + assert.match(pythonReadme, /python -m pip install yieldskill/) + assert.match(pythonReadme, /https:\/\/pypi\.org\/project\/yieldskill\//) + assert.doesNotMatch(pythonReadme, /get\.operatorstack\.systems\/pip/) + assert.match(rustReadme, /cargo install yieldskill --locked/) + assert.doesNotMatch(rustReadme, /get\.operatorstack\.systems\/cargo/) + assert.match(goReadme, /go install github\.com\/operatorstack\/yield\/cmd\/yskill@latest/) + assert.match( + docsIndex, + /\[public documentation\]\(https:\/\/yield\.operatorstack\.systems\/docs\/\)/, + ) const commands = [ "npm create @operatorstack/yield@latest", "uvx --from yieldskill yskill bootstrap --language python", "yskill bootstrap --language rust", "go run github.com/operatorstack/yield/cmd/yskill@latest bootstrap --language go", - ]; + ] for (const command of commands) { - assert.ok(readme.includes(command), `README is missing ${command}`); - assert.ok(quickstart.includes(command), `quickstart is missing ${command}`); - assert.ok(agentSetup.includes(command), `agent setup is missing ${command}`); + assert.ok(readme.includes(command), `README is missing ${command}`) + assert.ok(quickstart.includes(command), `quickstart is missing ${command}`) + assert.ok(agentSetup.includes(command), `agent setup is missing ${command}`) } - assert.doesNotMatch(quickstart, /get\.operatorstack\.systems\/npm|@operatorstack\/yield@0\./); - const createRequest = "Use Yield to create a tested skill workflow for releasing my package."; - const convertRequest = "Use Yield to convert my existing release SKILL.md into a tested skill workflow."; + assert.doesNotMatch(quickstart, /get\.operatorstack\.systems\/npm|@operatorstack\/yield@0\./) + const createRequest = "Use Yield to create a tested skill workflow for releasing my package." + const convertRequest = + "Use Yield to convert my existing release SKILL.md into a tested skill workflow." for (const document of [readme, quickstart, agentSetup, pythonReadme, rustReadme, goReadme]) { - assert.ok(document.includes(createRequest), "agent-first documentation is missing the create request"); - assert.ok(document.includes(convertRequest), "agent-first documentation is missing the convert request"); + assert.ok( + document.includes(createRequest), + "agent-first documentation is missing the create request", + ) + assert.ok( + document.includes(convertRequest), + "agent-first documentation is missing the convert request", + ) } - assert.match(quickstart, /^## Advanced: build manually$/m); - assert.match(agentSetup, /^## Run the registered skill$/m); -}); + assert.match(quickstart, /^## Advanced: build manually$/m) + assert.match(agentSetup, /^## Run the registered skill$/m) +}) test("root README links survive npm package rendering", async () => { - const readme = await text("README.md"); - assert.match(readme, /https:\/\/github\.com\/operatorstack\/yield\/blob\/main\/docs\/skill-workflows\.md/); - assert.match(readme, /https:\/\/github\.com\/operatorstack\/yield\/blob\/main\/evals\/README\.md/); - assert.match(readme, /https:\/\/github\.com\/operatorstack\/yield\/tree\/main\/examples\/library\//); - assert.doesNotMatch(readme, /\]\((?!https?:\/\/|#|mailto:)[^)]+\)/); - assert.doesNotMatch(readme, /href="(?!https?:\/\/|#|mailto:)[^"]+"/); -}); + const readme = await text("README.md") + assert.match( + readme, + /https:\/\/github\.com\/operatorstack\/yield\/blob\/main\/docs\/skill-workflows\.md/, + ) + assert.match(readme, /https:\/\/github\.com\/operatorstack\/yield\/blob\/main\/evals\/README\.md/) + assert.match( + readme, + /https:\/\/github\.com\/operatorstack\/yield\/tree\/main\/examples\/library\//, + ) + assert.doesNotMatch(readme, /\]\((?!https?:\/\/|#|mailto:)[^)]+\)/) + assert.doesNotMatch(readme, /href="(?!https?:\/\/|#|mailto:)[^"]+"/) +}) diff --git a/scripts/release-plan.mjs b/scripts/release-plan.mjs index b1ebb72..5ddda65 100644 --- a/scripts/release-plan.mjs +++ b/scripts/release-plan.mjs @@ -1,91 +1,116 @@ #!/usr/bin/env node -import { execFileSync } from "node:child_process"; -import { readFile, writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import process from "node:process"; -import { pathToFileURL } from "node:url"; -import { parse as parseYaml } from "yaml"; +import { execFileSync } from "node:child_process" +import { readFile, writeFile } from "node:fs/promises" +import { resolve } from "node:path" +import process from "node:process" +import { pathToFileURL } from "node:url" +import { parse as parseYaml } from "yaml" -const PACKAGE = "@operatorstack/yield"; -const levels = { patch: 0, minor: 1, major: 2 }; +const PACKAGE = "@operatorstack/yield" +const levels = { patch: 0, minor: 1, major: 2 } function parseArgs(argv) { - const result = {}; + const result = {} for (let index = 0; index < argv.length; index += 2) { - const key = argv[index]; - if (!key?.startsWith("--") || argv[index + 1] === undefined) throw new Error(`invalid argument ${key ?? ""}`); - result[key.slice(2)] = argv[index + 1]; + const key = argv[index] + if (!key?.startsWith("--") || argv[index + 1] === undefined) + throw new Error(`invalid argument ${key ?? ""}`) + result[key.slice(2)] = argv[index + 1] } - return result; + return result } function git(args) { - return execFileSync("git", args, { encoding: "utf8" }).trim(); + return execFileSync("git", args, { encoding: "utf8" }).trim() } export function parseChangeset(text, path = "changeset") { - const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]+)$/.exec(text); - if (!match) throw new Error(`${path}: expected YAML frontmatter and a summary`); - const releases = parseYaml(match[1]); - if (!releases || typeof releases !== "object" || Array.isArray(releases)) throw new Error(`${path}: frontmatter must be a package map`); - const entries = Object.entries(releases); - if (entries.length !== 1 || entries[0][0] !== PACKAGE) throw new Error(`${path}: only ${PACKAGE} may declare release intent`); - const bump = entries[0][1]; - if (!(bump in levels)) throw new Error(`${path}: bump must be patch, minor, or major`); - const summary = match[2].trim(); - if (!summary) throw new Error(`${path}: summary must not be empty`); - return { bump, summary, path }; + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]+)$/.exec(text) + if (!match) throw new Error(`${path}: expected YAML frontmatter and a summary`) + const releases = parseYaml(match[1]) + if (!releases || typeof releases !== "object" || Array.isArray(releases)) + throw new Error(`${path}: frontmatter must be a package map`) + const entries = Object.entries(releases) + if (entries.length !== 1 || entries[0][0] !== PACKAGE) + throw new Error(`${path}: only ${PACKAGE} may declare release intent`) + const bump = entries[0][1] + if (!(bump in levels)) throw new Error(`${path}: bump must be patch, minor, or major`) + const summary = match[2].trim() + if (!summary) throw new Error(`${path}: summary must not be empty`) + return { bump, summary, path } } export function bumpVersion(version, bump) { - if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error(`invalid base version ${version}`); - const [major, minor, patch] = version.split(".").map(Number); - if (bump === "major") return `${major + 1}.0.0`; - if (bump === "minor") return `${major}.${minor + 1}.0`; - if (bump === "patch") return `${major}.${minor}.${patch + 1}`; - throw new Error(`invalid bump ${bump}`); + if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error(`invalid base version ${version}`) + const [major, minor, patch] = version.split(".").map(Number) + if (bump === "major") return `${major + 1}.0.0` + if (bump === "minor") return `${major}.${minor + 1}.0` + if (bump === "patch") return `${major}.${minor}.${patch + 1}` + throw new Error(`invalid bump ${bump}`) } export function planRelease({ baseVersion, changesets, requestedBump = "auto" }) { - if (!changesets.length) throw new Error("stable releases require at least one pending Changeset"); - if (requestedBump !== "auto" && !(requestedBump in levels)) throw new Error(`invalid requested bump ${requestedBump}`); - const declaredBump = changesets.map(({ bump }) => bump).sort((a, b) => levels[b] - levels[a])[0]; + if (!changesets.length) throw new Error("stable releases require at least one pending Changeset") + if (requestedBump !== "auto" && !(requestedBump in levels)) + throw new Error(`invalid requested bump ${requestedBump}`) + const declaredBump = changesets.map(({ bump }) => bump).sort((a, b) => levels[b] - levels[a])[0] if (requestedBump !== "auto" && levels[requestedBump] < levels[declaredBump]) { - throw new Error(`requested ${requestedBump} cannot lower declared ${declaredBump}`); + throw new Error(`requested ${requestedBump} cannot lower declared ${declaredBump}`) } - const bump = requestedBump === "auto" ? declaredBump : requestedBump; - return { baseVersion, bump, version: bumpVersion(baseVersion, bump), changesets }; + const bump = requestedBump === "auto" ? declaredBump : requestedBump + return { baseVersion, bump, version: bumpVersion(baseVersion, bump), changesets } } async function main() { - const args = parseArgs(process.argv.slice(2)); - const requestedBump = args.bump ?? "auto"; - const baseTag = args.base ?? git(["tag", "--list", "v[0-9]*", "--sort=-v:refname"]).split("\n")[0]; - if (!/^v\d+\.\d+\.\d+$/.test(baseTag)) throw new Error("no valid stable base tag found"); - git(["rev-parse", "--verify", `refs/tags/${baseTag}`]); - const paths = git(["diff", "--name-only", "--diff-filter=A", `${baseTag}..HEAD`, "--", ".changeset/*.md"]) + const args = parseArgs(process.argv.slice(2)) + const requestedBump = args.bump ?? "auto" + const baseTag = args.base ?? git(["tag", "--list", "v[0-9]*", "--sort=-v:refname"]).split("\n")[0] + if (!/^v\d+\.\d+\.\d+$/.test(baseTag)) throw new Error("no valid stable base tag found") + git(["rev-parse", "--verify", `refs/tags/${baseTag}`]) + const paths = git([ + "diff", + "--name-only", + "--diff-filter=A", + `${baseTag}..HEAD`, + "--", + ".changeset/*.md", + ]) .split("\n") - .filter((path) => path && path !== ".changeset/README.md"); - const changesets = []; - for (const path of paths) changesets.push(parseChangeset(await readFile(path, "utf8"), path)); - const plan = planRelease({ baseVersion: baseTag.slice(1), changesets, requestedBump }); - const sourceSha = git(["rev-parse", "HEAD"]); - const notes = [`# Yield ${plan.version}`, "", ...plan.changesets.flatMap(({ summary }) => [`- ${summary}`, ""])].join("\n").trimEnd() + "\n"; - if (args.notes) await writeFile(args.notes, notes); - if (args.output) { - await writeFile(args.output, [ - `base_tag=${baseTag}`, - `bump=${plan.bump}`, - `version=${plan.version}`, - `tag=v${plan.version}`, - `source_sha=${sourceSha}`, - `changeset_count=${plan.changesets.length}`, + .filter((path) => path && path !== ".changeset/README.md") + const changesets = [] + for (const path of paths) changesets.push(parseChangeset(await readFile(path, "utf8"), path)) + const plan = planRelease({ baseVersion: baseTag.slice(1), changesets, requestedBump }) + const sourceSha = git(["rev-parse", "HEAD"]) + const notes = + [ + `# Yield ${plan.version}`, "", - ].join("\n"), { flag: "a" }); + ...plan.changesets.flatMap(({ summary }) => [`- ${summary}`, ""]), + ] + .join("\n") + .trimEnd() + "\n" + if (args.notes) await writeFile(args.notes, notes) + if (args.output) { + await writeFile( + args.output, + [ + `base_tag=${baseTag}`, + `bump=${plan.bump}`, + `version=${plan.version}`, + `tag=v${plan.version}`, + `source_sha=${sourceSha}`, + `changeset_count=${plan.changesets.length}`, + "", + ].join("\n"), + { flag: "a" }, + ) } - process.stdout.write(`${JSON.stringify({ ...plan, baseTag, sourceSha }, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ ...plan, baseTag, sourceSha }, null, 2)}\n`) } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { - main().catch((error) => { console.error(`release-plan: ${error.message}`); process.exit(1); }); + main().catch((error) => { + console.error(`release-plan: ${error.message}`) + process.exit(1) + }) } diff --git a/scripts/release-plan.test.mjs b/scripts/release-plan.test.mjs index 5ca4a25..c115805 100644 --- a/scripts/release-plan.test.mjs +++ b/scripts/release-plan.test.mjs @@ -1,29 +1,52 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { bumpVersion, parseChangeset, planRelease } from "./release-plan.mjs"; +import test from "node:test" +import assert from "node:assert/strict" +import { bumpVersion, parseChangeset, planRelease } from "./release-plan.mjs" -const changeset = (bump, summary = "Ship it") => parseChangeset(`---\n"@operatorstack/yield": ${bump}\n---\n\n${summary}\n`); +const changeset = (bump, summary = "Ship it") => + parseChangeset(`---\n"@operatorstack/yield": ${bump}\n---\n\n${summary}\n`) test("aggregates the highest pending Changeset bump", () => { - assert.equal(planRelease({ baseVersion: "0.1.29", changesets: [changeset("patch"), changeset("minor")] }).version, "0.2.0"); -}); + assert.equal( + planRelease({ baseVersion: "0.1.29", changesets: [changeset("patch"), changeset("minor")] }) + .version, + "0.2.0", + ) +}) test("allows an explicit bump to raise but not lower intent", () => { - assert.equal(planRelease({ baseVersion: "0.1.29", changesets: [changeset("patch")], requestedBump: "major" }).version, "1.0.0"); - assert.throws(() => planRelease({ baseVersion: "0.1.29", changesets: [changeset("major")], requestedBump: "minor" }), /cannot lower/); -}); + assert.equal( + planRelease({ baseVersion: "0.1.29", changesets: [changeset("patch")], requestedBump: "major" }) + .version, + "1.0.0", + ) + assert.throws( + () => + planRelease({ + baseVersion: "0.1.29", + changesets: [changeset("major")], + requestedBump: "minor", + }), + /cannot lower/, + ) +}) test("requires pending release intent", () => { - assert.throws(() => planRelease({ baseVersion: "0.1.29", changesets: [] }), /at least one/); -}); + assert.throws(() => planRelease({ baseVersion: "0.1.29", changesets: [] }), /at least one/) +}) test("rejects another package or malformed bump", () => { - assert.throws(() => parseChangeset(`---\nother: patch\n---\n\nNo\n`), /only @operatorstack\/yield/); - assert.throws(() => parseChangeset(`---\n"@operatorstack/yield": huge\n---\n\nNo\n`), /patch, minor, or major/); -}); + assert.throws( + () => parseChangeset(`---\nother: patch\n---\n\nNo\n`), + /only @operatorstack\/yield/, + ) + assert.throws( + () => parseChangeset(`---\n"@operatorstack/yield": huge\n---\n\nNo\n`), + /patch, minor, or major/, + ) +}) test("applies ordinary semantic version increments", () => { - assert.equal(bumpVersion("0.1.29", "patch"), "0.1.30"); - assert.equal(bumpVersion("0.1.29", "minor"), "0.2.0"); - assert.equal(bumpVersion("0.1.29", "major"), "1.0.0"); -}); + assert.equal(bumpVersion("0.1.29", "patch"), "0.1.30") + assert.equal(bumpVersion("0.1.29", "minor"), "0.2.0") + assert.equal(bumpVersion("0.1.29", "major"), "1.0.0") +}) diff --git a/sdk/python/README.md b/sdk/python/README.md index 3ac252b..384cd89 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -87,11 +87,14 @@ python -m yieldskill init skills/env-doctor \ Replace `skills/env-doctor/main.py` with this tested workflow: + ```python from yieldskill import define_skill def program(ctx): - probe = ctx.run_command("probe-python", "python3 --version || python --version", timeout_seconds=60) + probe = ctx.run_command( + "probe-python", "python3 --version || python --version", timeout_seconds=60 + ) diagnosis = ctx.agent_task( "diagnose", @@ -116,7 +119,9 @@ def program(ctx): ) if answer != "done": ctx.blocked("the environment fix was not applied") - recheck = ctx.run_command("recheck-python", "python3 --version || python --version", timeout_seconds=60) + recheck = ctx.run_command( + "recheck-python", "python3 --version || python --version", timeout_seconds=60 + ) ctx.require(recheck.exit_code == 0, "the environment probe passes after the fix", recheck) return {"healthy": True, "fixed": True} @@ -126,6 +131,7 @@ def program(ctx): define_skill(program) ``` + The generated `skill.json` declares Python as the runner. The generated @@ -199,13 +205,13 @@ for each required agent or user response. Replay must produce the same operation sequence. Yield reports divergence instead of giving a recorded response to a different operation. -| Python primitive | Purpose | -|---|---| -| `ctx.run_command()` | Execute a command and record its exit code and output. | -| `ctx.agent_task()` | Ask the coding agent for schema-valid JSON. | -| `ctx.ask_user()` | Request an explicit human decision. | -| `ctx.require()` | Bind a required claim to recorded evidence. | -| `ctx.blocked()` / `ctx.refused()` | Stop honestly when work cannot or must not continue. | +| Python primitive | Purpose | +| --------------------------------- | ------------------------------------------------------ | +| `ctx.run_command()` | Execute a command and record its exit code and output. | +| `ctx.agent_task()` | Ask the coding agent for schema-valid JSON. | +| `ctx.ask_user()` | Request an explicit human decision. | +| `ctx.require()` | Bind a required claim to recorded evidence. | +| `ctx.blocked()` / `ctx.refused()` | Stop honestly when work cannot or must not continue. | See the [primitive guides](https://yield.operatorstack.systems/docs/primitives/) and [CLI reference](https://github.com/operatorstack/yield/blob/main/docs/reference/cli.md) diff --git a/sdk/python/test_cli.py b/sdk/python/test_cli.py index 9dde47f..f6478d1 100644 --- a/sdk/python/test_cli.py +++ b/sdk/python/test_cli.py @@ -23,14 +23,19 @@ def test_unix_replaces_process_and_forwards_arguments(self) -> None: binary.touch() with mock.patch.object(_cli, "runtime_path", return_value=binary): with mock.patch.dict(os.environ, {}, clear=True): - with mock.patch.object(os, "execve", side_effect=RuntimeError("exec")) as execute: + with mock.patch.object( + os, "execve", side_effect=RuntimeError("exec") + ) as execute: with self.assertRaisesRegex(RuntimeError, "exec"): _cli.run(["test", "skill"], "linux") call = execute.call_args self.assertEqual(call.args[:2], (str(binary), [str(binary), "test", "skill"])) self.assertEqual(call.args[2]["YIELD_LANGUAGE"], "python") self.assertEqual(call.args[2]["YIELD_PYTHON"], os.sys.executable) - self.assertEqual(call.args[2]["PATH"].split(os.pathsep)[0], str(Path(os.sys.executable).absolute().parent)) + self.assertEqual( + call.args[2]["PATH"].split(os.pathsep)[0], + str(Path(os.sys.executable).absolute().parent), + ) def test_windows_preserves_exit_code_and_arguments(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -39,14 +44,19 @@ def test_windows_preserves_exit_code_and_arguments(self) -> None: completed = mock.Mock(returncode=23) with mock.patch.object(_cli, "runtime_path", return_value=binary): with mock.patch.dict(os.environ, {}, clear=True): - with mock.patch.object(_cli.subprocess, "run", return_value=completed) as execute: + with mock.patch.object( + _cli.subprocess, "run", return_value=completed + ) as execute: self.assertEqual(_cli.run(["--version"], "win32"), 23) call = execute.call_args self.assertEqual(call.args[0], [str(binary), "--version"]) self.assertFalse(call.kwargs["check"]) self.assertEqual(call.kwargs["env"]["YIELD_LANGUAGE"], "python") self.assertEqual(call.kwargs["env"]["YIELD_PYTHON"], os.sys.executable) - self.assertEqual(call.kwargs["env"]["PATH"].split(os.pathsep)[0], str(Path(os.sys.executable).absolute().parent)) + self.assertEqual( + call.kwargs["env"]["PATH"].split(os.pathsep)[0], + str(Path(os.sys.executable).absolute().parent), + ) def test_selected_virtual_environment_is_first_on_path(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -56,8 +66,12 @@ def test_selected_virtual_environment_is_first_on_path(self) -> None: python.parent.mkdir(parents=True) python.touch() with mock.patch.object(_cli, "runtime_path", return_value=binary): - with mock.patch.dict(os.environ, {"YIELD_PYTHON": str(python), "PATH": "/usr/bin"}, clear=True): - with mock.patch.object(os, "execve", side_effect=RuntimeError("exec")) as execute: + with mock.patch.dict( + os.environ, {"YIELD_PYTHON": str(python), "PATH": "/usr/bin"}, clear=True + ): + with mock.patch.object( + os, "execve", side_effect=RuntimeError("exec") + ) as execute: with self.assertRaisesRegex(RuntimeError, "exec"): _cli.run([], "linux") environment = execute.call_args.args[2] @@ -75,8 +89,12 @@ def test_virtual_environment_symlink_keeps_its_lexical_bin_directory(self) -> No python.parent.mkdir(parents=True) python.symlink_to(system) with mock.patch.object(_cli, "runtime_path", return_value=binary): - with mock.patch.dict(os.environ, {"YIELD_PYTHON": str(python), "PATH": "/usr/bin"}, clear=True): - with mock.patch.object(os, "execve", side_effect=RuntimeError("exec")) as execute: + with mock.patch.dict( + os.environ, {"YIELD_PYTHON": str(python), "PATH": "/usr/bin"}, clear=True + ): + with mock.patch.object( + os, "execve", side_effect=RuntimeError("exec") + ) as execute: with self.assertRaisesRegex(RuntimeError, "exec"): _cli.run([], "linux") environment = execute.call_args.args[2] diff --git a/sdk/python/yieldskill/__init__.py b/sdk/python/yieldskill/__init__.py index 1e4da7a..d5b9040 100644 --- a/sdk/python/yieldskill/__init__.py +++ b/sdk/python/yieldskill/__init__.py @@ -194,7 +194,7 @@ def _step(self, req: dict) -> dict: "detail": ( f'replay produced operation "{req["id"]}" ({req["kind"]}) ' f'where the journal recorded "{entry["request"]["id"]}" ' - f'({entry["request"]["kind"]})' + f"({entry['request']['kind']})" ), }, } diff --git a/sdk/python/yieldskill/__pycache__/__init__.cpython-314.pyc b/sdk/python/yieldskill/__pycache__/__init__.cpython-314.pyc index a3d245a..9641e4f 100644 Binary files a/sdk/python/yieldskill/__pycache__/__init__.cpython-314.pyc and b/sdk/python/yieldskill/__pycache__/__init__.cpython-314.pyc differ diff --git a/sdk/rust/README.md b/sdk/rust/README.md index f877c6c..9bb1c7f 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -77,6 +77,7 @@ yskill init skills/data-migration \ Replace `skills/data-migration/src/main.rs` with this tested workflow: + ```rust use serde_json::json; use yieldskill::{define_skill, Context, SkillResult}; @@ -133,6 +134,7 @@ fn main() { define_skill(program); } ``` + The generated `Cargo.toml` pins the public `yieldskill` crate to the installed @@ -144,6 +146,7 @@ Use deterministic fixture responses during tests. Save this as `skills/data-migration/fixtures/responses.json`: + ```json { "summarize-plan": { @@ -152,6 +155,7 @@ Use deterministic fixture responses during tests. Save this as "approve-apply": { "value": "apply" } } ``` + Then test the workflow: @@ -211,13 +215,13 @@ each required agent or user response. Replay must produce the same operation sequence. Yield reports divergence instead of giving a recorded response to a different operation. -| Rust primitive | Purpose | -|---|---| -| `ctx.run_command()` | Execute a command and record its exit code and output. | -| `ctx.agent_task()` | Ask the coding agent for schema-valid JSON. | -| `ctx.ask_user()` | Request an explicit human decision. | -| `ctx.require()` | Bind a required claim to recorded evidence. | -| `ctx.blocked()` / `ctx.refused()` | Stop honestly when work cannot or must not continue. | +| Rust primitive | Purpose | +| --------------------------------- | ------------------------------------------------------ | +| `ctx.run_command()` | Execute a command and record its exit code and output. | +| `ctx.agent_task()` | Ask the coding agent for schema-valid JSON. | +| `ctx.ask_user()` | Request an explicit human decision. | +| `ctx.require()` | Bind a required claim to recorded evidence. | +| `ctx.blocked()` / `ctx.refused()` | Stop honestly when work cannot or must not continue. | See the [primitive guides](https://yield.operatorstack.systems/docs/primitives/) and [CLI reference](https://github.com/operatorstack/yield/blob/main/docs/reference/cli.md) diff --git a/sdk/typescript/bin/runtime.mjs b/sdk/typescript/bin/runtime.mjs index 255b3cc..5784eb5 100644 --- a/sdk/typescript/bin/runtime.mjs +++ b/sdk/typescript/bin/runtime.mjs @@ -1,4 +1,4 @@ -import { createRequire } from "node:module"; +import { createRequire } from "node:module" const packages = new Map([ ["darwin:x64", "@operatorstack/yield-darwin-amd64"], @@ -7,14 +7,14 @@ const packages = new Map([ ["linux:arm64", "@operatorstack/yield-linux-arm64"], ["win32:x64", "@operatorstack/yield-windows-amd64"], ["win32:arm64", "@operatorstack/yield-windows-arm64"], -]); +]) export function runtimePackage(platform = process.platform, arch = process.arch) { - const name = packages.get(`${platform}:${arch}`); + const name = packages.get(`${platform}:${arch}`) if (!name) { - throw new Error(`Yield does not provide a runtime for ${platform}/${arch}`); + throw new Error(`Yield does not provide a runtime for ${platform}/${arch}`) } - return name; + return name } export function resolveRuntime({ @@ -22,13 +22,13 @@ export function resolveRuntime({ arch = process.arch, resolve = createRequire(import.meta.url).resolve, } = {}) { - const name = runtimePackage(platform, arch); + const name = runtimePackage(platform, arch) try { - return resolve(name); + return resolve(name) } catch (error) { throw new Error( `The runtime package ${name} is missing. Reinstall @operatorstack/yield for ${platform}/${arch}.`, { cause: error }, - ); + ) } } diff --git a/sdk/typescript/bin/runtime.test.mjs b/sdk/typescript/bin/runtime.test.mjs index bcb2e77..44e44d7 100644 --- a/sdk/typescript/bin/runtime.test.mjs +++ b/sdk/typescript/bin/runtime.test.mjs @@ -1,6 +1,6 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { resolveRuntime, runtimePackage } from "./runtime.mjs"; +import test from "node:test" +import assert from "node:assert/strict" +import { resolveRuntime, runtimePackage } from "./runtime.mjs" const cases = [ ["darwin", "x64", "@operatorstack/yield-darwin-amd64"], @@ -9,22 +9,32 @@ const cases = [ ["linux", "arm64", "@operatorstack/yield-linux-arm64"], ["win32", "x64", "@operatorstack/yield-windows-amd64"], ["win32", "arm64", "@operatorstack/yield-windows-arm64"], -]; +] test("selects the exact runtime package for every supported target", () => { for (const [platform, arch, expected] of cases) { - assert.equal(runtimePackage(platform, arch), expected); - assert.equal(resolveRuntime({ platform, arch, resolve: (name) => `/packages/${name}` }), `/packages/${expected}`); + assert.equal(runtimePackage(platform, arch), expected) + assert.equal( + resolveRuntime({ platform, arch, resolve: (name) => `/packages/${name}` }), + `/packages/${expected}`, + ) } -}); +}) test("rejects unsupported targets", () => { - assert.throws(() => runtimePackage("freebsd", "x64"), /does not provide a runtime/); -}); + assert.throws(() => runtimePackage("freebsd", "x64"), /does not provide a runtime/) +}) test("does not fall back when the selected package is missing", () => { assert.throws( - () => resolveRuntime({ platform: "linux", arch: "x64", resolve: () => { throw new Error("missing"); } }), + () => + resolveRuntime({ + platform: "linux", + arch: "x64", + resolve: () => { + throw new Error("missing") + }, + }), /Reinstall @operatorstack\/yield/, - ); -}); + ) +}) diff --git a/sdk/typescript/bin/yskill.mjs b/sdk/typescript/bin/yskill.mjs index b8c2988..08d9a55 100644 --- a/sdk/typescript/bin/yskill.mjs +++ b/sdk/typescript/bin/yskill.mjs @@ -1,33 +1,33 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; -import process from "node:process"; -import { resolveRuntime } from "./runtime.mjs"; +import { spawn } from "node:child_process" +import process from "node:process" +import { resolveRuntime } from "./runtime.mjs" -let binary; +let binary try { - binary = resolveRuntime(); + binary = resolveRuntime() } catch (error) { - console.error(`yskill: ${error.message}`); - process.exit(1); + console.error(`yskill: ${error.message}`) + process.exit(1) } const child = spawn(binary, process.argv.slice(2), { stdio: "inherit", env: { ...process.env, YIELD_LANGUAGE: process.env.YIELD_LANGUAGE ?? "typescript" }, -}); +}) for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { process.on(signal, () => { - if (!child.killed) child.kill(signal); - }); + if (!child.killed) child.kill(signal) + }) } child.on("error", (error) => { - console.error(`yskill: could not start the packaged runtime: ${error.message}`); - process.exit(1); -}); + console.error(`yskill: could not start the packaged runtime: ${error.message}`) + process.exit(1) +}) child.on("exit", (code, signal) => { if (signal && process.platform !== "win32") { - process.kill(process.pid, signal); - return; + process.kill(process.pid, signal) + return } - process.exit(code ?? 1); -}); + process.exit(code ?? 1) +}) diff --git a/sdk/typescript/scripts/build.mjs b/sdk/typescript/scripts/build.mjs index a97e8f5..25c89ad 100644 --- a/sdk/typescript/scripts/build.mjs +++ b/sdk/typescript/scripts/build.mjs @@ -1,19 +1,19 @@ -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { stripTypeScriptTypes } from "node:module"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { stripTypeScriptTypes } from "node:module" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" -const here = dirname(fileURLToPath(import.meta.url)); -const root = resolve(here, ".."); -const sourcePath = resolve(root, "src/index.ts"); -const distPath = resolve(root, "dist"); -const outputPath = resolve(distPath, "index.js"); -const source = readFileSync(sourcePath, "utf8"); -const runtime = stripTypeScriptTypes(source, { mode: "transform" }); +const here = dirname(fileURLToPath(import.meta.url)) +const root = resolve(here, "..") +const sourcePath = resolve(root, "src/index.ts") +const distPath = resolve(root, "dist") +const outputPath = resolve(distPath, "index.js") +const source = readFileSync(sourcePath, "utf8") +const runtime = stripTypeScriptTypes(source, { mode: "transform" }) -rmSync(distPath, { recursive: true, force: true }); -mkdirSync(dirname(outputPath), { recursive: true }); +rmSync(distPath, { recursive: true, force: true }) +mkdirSync(dirname(outputPath), { recursive: true }) writeFileSync( outputPath, "// Generated from src/index.ts by scripts/build.mjs. Do not edit.\n" + runtime, -); +) diff --git a/sdk/yield/README.md b/sdk/yield/README.md index 46847aa..9c74f72 100644 --- a/sdk/yield/README.md +++ b/sdk/yield/README.md @@ -76,6 +76,7 @@ yskill init skills/investigate \ Replace `skills/investigate/main.go` with this tested workflow: + ```go package main @@ -172,6 +173,7 @@ func main() { }) } ``` + The generated `go.mod` pins the public Yield module to the installed CLI @@ -183,6 +185,7 @@ Use deterministic responses during tests. Save this as `skills/investigate/fixtures/responses.json`: + ```json { "collect-evidence": { @@ -220,6 +223,7 @@ Use deterministic responses during tests. Save this as } } ``` + Then test the workflow: @@ -279,13 +283,13 @@ each required agent response. Replay must produce the same operation sequence. Yield reports divergence instead of giving a recorded response to a different operation. -| Go primitive | Purpose | -|---|---| -| `ctx.RunCommand()` | Execute a command and record its exit code and output. | -| `ctx.AgentTask()` | Ask the coding agent for schema-valid JSON. | -| `ctx.AskUser()` | Request an explicit human decision. | -| `ctx.Require()` | Bind a required claim to recorded evidence. | -| `ctx.Blocked()` / `ctx.Refused()` | Stop honestly when work cannot or must not continue. | +| Go primitive | Purpose | +| --------------------------------- | ------------------------------------------------------ | +| `ctx.RunCommand()` | Execute a command and record its exit code and output. | +| `ctx.AgentTask()` | Ask the coding agent for schema-valid JSON. | +| `ctx.AskUser()` | Request an explicit human decision. | +| `ctx.Require()` | Bind a required claim to recorded evidence. | +| `ctx.Blocked()` / `ctx.Refused()` | Stop honestly when work cannot or must not continue. | See the [Go reference](https://pkg.go.dev/github.com/operatorstack/yield/sdk/yield), [primitive guides](https://yield.operatorstack.systems/docs/primitives/), and diff --git a/skills/release-yield/main.ts b/skills/release-yield/main.ts index 5b3317f..3e3122e 100644 --- a/skills/release-yield/main.ts +++ b/skills/release-yield/main.ts @@ -1,4 +1,4 @@ -import { defineSkill } from "@operatorstack/yield"; -import { runReleaseYield } from "./src/workflow.ts"; +import { defineSkill } from "@operatorstack/yield" +import { runReleaseYield } from "./src/workflow.ts" -defineSkill(runReleaseYield); +defineSkill(runReleaseYield) diff --git a/skills/release-yield/skill.json b/skills/release-yield/skill.json index a09b572..e54de2c 100644 --- a/skills/release-yield/skill.json +++ b/skills/release-yield/skill.json @@ -1,8 +1,5 @@ { "version": 1, "language": "typescript", - "run": [ - "node", - "main.ts" - ] + "run": ["node", "main.ts"] } diff --git a/skills/release-yield/src/release-controller.mjs b/skills/release-yield/src/release-controller.mjs index 45c1d1b..4eda28c 100644 --- a/skills/release-yield/src/release-controller.mjs +++ b/skills/release-yield/src/release-controller.mjs @@ -1,14 +1,14 @@ #!/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"]); +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/create-yield", @@ -18,7 +18,7 @@ const npmPackages = [ "@operatorstack/yield-linux-arm64", "@operatorstack/yield-windows-amd64", "@operatorstack/yield-windows-arm64", -]; +] const crates = [ "yieldskill", "yieldskill-runtime-darwin-amd64", @@ -27,190 +27,322 @@ const crates = [ "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 = {}; + 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]; + 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 }; + 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(); + 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)); +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))) + 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; + .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"); + 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"; +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); + const found = selectNewRun(listRuns(workflow), previous === "none" ? "" : previous, options) + if (found) return found + await sleep(2000) } - throw new Blocked(`no new ${workflow} run appeared`); + 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; + 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$/, ""); + return value.replace(/^git@github\.com:/, "https://github.com/").replace(/\.git$/, "") } function pendingDeployments(runID) { - return ghJSON("api", `repos/${repository}/actions/runs/${runID}/pending_deployments`); + 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); + 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." }), - ); + [ + "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`); + throw new Blocked( + `GitHub refused approval for ${names.join(", ")}; approve the environments at the workflow run`, + ) } - return names; + return names } async function waitRun(runID, expectedEnvironments = new Set()) { - const seen = 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"); + 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() }; + if (runInfo.conclusion !== "success") + throw new Failed(`workflow run ${runID} concluded ${runInfo.conclusion}`) + return { ...runInfo, environments: [...seen].sort() } } - await sleep(2000); + await sleep(2000) } - throw new Blocked(`workflow run ${runID} did not complete before the timeout`); + 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; + 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`); + gh("auth", "status") + protection = ghJSON("api", `repos/${repository}/branches/main/protection`) } catch { - throw new Blocked("GitHub authentication cannot inspect the protected main branch"); + 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 }; + 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"); + 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"]}`); + 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"); + throw new Blocked("GitHub refused the release workflow dispatch") } - const found = await discover("release.yml", releaseBaseline, { event: "workflow_dispatch", sourceSha }); + 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 }; + 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 }; + 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 }; + 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 }; + 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"); + 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 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`); + 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`); + 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`); + 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, @@ -219,32 +351,40 @@ async function verify(values) { 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 === "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 }; + 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}`); + 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`); + 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`); + 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(); +if ( + process.argv[1] && + (await realpath(process.argv[1])) === (await realpath(fileURLToPath(import.meta.url))) +) + await main() diff --git a/skills/release-yield/src/release-controller.test.mjs b/skills/release-yield/src/release-controller.test.mjs index e0e85ed..b9342ec 100644 --- a/skills/release-yield/src/release-controller.test.mjs +++ b/skills/release-yield/src/release-controller.test.mjs @@ -1,24 +1,32 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { Blocked, selectNewRun } from "./release-controller.mjs"; +import test from "node:test" +import assert from "node:assert/strict" +import { Blocked, selectNewRun } from "./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); -}); + 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); -}); + 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" }), + () => + selectNewRun( + [...runs, { databaseId: 4, event: "workflow_dispatch", headSha: "abc" }], + "1,2", + { event: "workflow_dispatch", sourceSha: "abc" }, + ), Blocked, - ); -}); + ) +}) diff --git a/skills/release-yield/src/workflow.test.mjs b/skills/release-yield/src/workflow.test.mjs index 88c302e..cee9ff7 100644 --- a/skills/release-yield/src/workflow.test.mjs +++ b/skills/release-yield/src/workflow.test.mjs @@ -1,155 +1,234 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { runReleaseYield } from "./workflow.ts"; +import test from "node:test" +import assert from "node:assert/strict" +import { runReleaseYield } from "./workflow.ts" -const sha = "a".repeat(40); +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" }, + "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", - changesets: [{ path: ".changeset/example.md", bump: "patch", summary: "Improve release confirmation." }], + status: "ok", + source_sha: sha, + version: "1.2.3", + tag: "v1.2.3", + changesets: [ + { path: ".changeset/example.md", bump: "patch", summary: "Improve release confirmation." }, + ], }, "dispatch-release": { - status: "ok", source_sha: sha, run_id: "11", run_url: "https://example.test/11", - publisher_baseline: "1,2", finalizer_baseline: "3,4", + 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", + 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: 8, pypi: { project: "yieldskill", wheels: 6 }, crates: 7, go: "github.com/operatorstack/yield" }, + targets: { + npm: 8, + pypi: { project: "yieldskill", wheels: 6 }, + crates: 7, + go: "github.com/operatorstack/yield", + }, }, ...overrides, - }; + } } -function context({ mode = "release", bump = "patch", confirmation = "confirm", authorization = "release", receipts = successReceipts() } = {}) { - const operations = []; +function context({ + mode = "release", + bump = "patch", + confirmation = "confirm", + authorization = "release", + receipts = successReceipts(), +} = {}) { + const operations = [] return { operations, askUser(id) { - operations.push(id); - if (id === "select-mode") return mode; - if (id === "select-bump") return bump; - if (id === "confirm-high-impact-bump") return confirmation; - return authorization; + operations.push(id) + if (id === "select-mode") return mode + if (id === "select-bump") return bump + if (id === "confirm-high-impact-bump") return confirmation + return 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: "" }; + 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}`); + if (!ok) throw new Error(`requirement_failed: ${claim}`) }, blocked(reason) { - throw new Error(`blocked: ${reason}`); + throw new Error(`blocked: ${reason}`) }, refused(reason) { - throw new Error(`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); + const ctx = context() + const result = runReleaseYield(ctx) assert.deepEqual(ctx.operations, [ - "select-mode", "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, 8); -}); + "select-mode", + "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, 8) +}) 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); -}); + 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("dry-run-only completes after the verified plan without asking for release authorization", () => { - const ctx = context({ mode: "dry-run" }); - const result = runReleaseYield(ctx); - assert.equal(result.mode, "dry-run"); - assert.equal(result.version, "1.2.3"); - assert.equal(result.changesets.length, 1); - assert.equal(ctx.operations.includes("authorize-release"), false); - assert.equal(ctx.operations.includes("dispatch-release"), false); -}); + const ctx = context({ mode: "dry-run" }) + const result = runReleaseYield(ctx) + assert.equal(result.mode, "dry-run") + assert.equal(result.version, "1.2.3") + assert.equal(result.changesets.length, 1) + assert.equal(ctx.operations.includes("authorize-release"), false) + assert.equal(ctx.operations.includes("dispatch-release"), false) +}) test("auto and patch bumps do not ask for high-impact confirmation", () => { for (const bump of ["auto", "patch"]) { - const ctx = context({ mode: "dry-run", bump }); - runReleaseYield(ctx); - assert.equal(ctx.operations.includes("confirm-high-impact-bump"), false); + const ctx = context({ mode: "dry-run", bump }) + runReleaseYield(ctx) + assert.equal(ctx.operations.includes("confirm-high-impact-bump"), false) } -}); +}) test("minor and major bumps require confirmation before preflight", () => { for (const bump of ["minor", "major"]) { - const ctx = context({ mode: "dry-run", bump }); - runReleaseYield(ctx); - assert.deepEqual(ctx.operations.slice(0, 4), ["select-mode", "select-bump", "confirm-high-impact-bump", "preflight"]); + const ctx = context({ mode: "dry-run", bump }) + runReleaseYield(ctx) + assert.deepEqual(ctx.operations.slice(0, 4), [ + "select-mode", + "select-bump", + "confirm-high-impact-bump", + "preflight", + ]) } -}); +}) test("cancelling a minor or major bump stops before any GitHub operation", () => { for (const bump of ["minor", "major"]) { - const ctx = context({ bump, confirmation: "cancel" }); - assert.throws(() => runReleaseYield(ctx), new RegExp(`refused: ${bump} release intent was not confirmed`)); - assert.deepEqual(ctx.operations, ["select-mode", "select-bump", "confirm-high-impact-bump"]); + const ctx = context({ bump, confirmation: "cancel" }) + assert.throws( + () => runReleaseYield(ctx), + new RegExp(`refused: ${bump} release intent was not confirmed`), + ) + assert.deepEqual(ctx.operations, ["select-mode", "select-bump", "confirm-high-impact-bump"]) } -}); +}) 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-mode", "select-bump", "preflight"]); -}); + 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-mode", "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", - changesets: [{ path: ".changeset/example.md", bump: "patch", summary: "Improve release confirmation." }], - } }) }); - assert.throws(() => runReleaseYield(ctx), /displayed plan uses the dry-run source SHA/); - assert.equal(ctx.operations.includes("authorize-release"), false); -}); + const ctx = context({ + receipts: successReceipts({ + "resolve-plan": { + status: "ok", + source_sha: "b".repeat(40), + version: "1.2.3", + tag: "v1.2.3", + changesets: [ + { + path: ".changeset/example.md", + bump: "patch", + summary: "Improve release confirmation.", + }, + ], + }, + }), + }) + 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(); + 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/); -}); + ctx.operations.push(id) + return { exit_code: 0, stdout: "not-json", stderr: "" } + } + assert.throws(() => runReleaseYield(ctx), /controller returned invalid JSON/) +}) test("rejects a timed-out controller operation", () => { - const ctx = context(); + const ctx = context() ctx.runCommand = (id) => { - ctx.operations.push(id); - return { exit_code: 0, timed_out: true, stdout: "", stderr: "controller timeout" }; - }; - assert.throws(() => runReleaseYield(ctx), /requirement_failed: the protected main preflight passes/); - assert.deepEqual(ctx.operations, ["select-mode", "select-bump", "preflight"]); -}); + ctx.operations.push(id) + return { exit_code: 0, timed_out: true, stdout: "", stderr: "controller timeout" } + } + assert.throws( + () => runReleaseYield(ctx), + /requirement_failed: the protected main preflight passes/, + ) + assert.deepEqual(ctx.operations, ["select-mode", "select-bump", "preflight"]) +}) test("rejects a malformed Changeset plan before release authorization", () => { - const ctx = context({ receipts: successReceipts({ "resolve-plan": { - status: "ok", source_sha: sha, version: "1.2.3", tag: "v1.2.3", changesets: [], - } }) }); - assert.throws(() => runReleaseYield(ctx), /release plan contains at least one Changeset/); - assert.equal(ctx.operations.includes("authorize-release"), false); -}); + const ctx = context({ + receipts: successReceipts({ + "resolve-plan": { + status: "ok", + source_sha: sha, + version: "1.2.3", + tag: "v1.2.3", + changesets: [], + }, + }), + }) + assert.throws(() => runReleaseYield(ctx), /release plan contains at least one Changeset/) + assert.equal(ctx.operations.includes("authorize-release"), false) +}) diff --git a/skills/release-yield/src/workflow.ts b/skills/release-yield/src/workflow.ts index d9ee368..27da557 100644 --- a/skills/release-yield/src/workflow.ts +++ b/skills/release-yield/src/workflow.ts @@ -1,82 +1,103 @@ -import type { CommandResult, Context } from "@operatorstack/yield"; +import type { CommandResult, Context } from "@operatorstack/yield" -export type ReleaseBump = "auto" | "patch" | "minor" | "major"; -export type ReleaseMode = "dry-run" | "release"; +export type ReleaseBump = "auto" | "patch" | "minor" | "major" +export type ReleaseMode = "dry-run" | "release" type Changeset = { - bump: "patch" | "minor" | "major"; - path: string; - summary: string; -}; + bump: "patch" | "minor" | "major" + path: string + summary: string +} type Receipt = { - status: "ok" | "blocked" | "failed"; - reason?: string; - [key: string]: unknown; -}; + status: "ok" | "blocked" | "failed" + reason?: string + [key: string]: unknown +} -type ReleaseContext = Pick; +type ReleaseContext = Pick -const controller = "node src/release-controller.mjs"; +const controller = "node src/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; + ctx.require(result.exit_code === 0 && !result.timed_out, claim, result) + let receipt: Receipt try { - receipt = JSON.parse(result.stdout.trim()) as Receipt; + receipt = JSON.parse(result.stdout.trim()) as Receipt } catch { - ctx.require(false, `${claim}: controller returned invalid JSON`, result); - throw new Error("unreachable"); + 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; + 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 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; + 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; +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 } function changesetsField(ctx: ReleaseContext, receipt: Receipt): Changeset[] { - const value = receipt.changesets; - ctx.require(Array.isArray(value) && value.length > 0, "the release plan contains at least one Changeset", receipt); + const value = receipt.changesets + ctx.require( + Array.isArray(value) && value.length > 0, + "the release plan contains at least one Changeset", + receipt, + ) for (const item of value as unknown[]) { - const candidate = item as Partial; + const candidate = item as Partial ctx.require( - typeof candidate?.path === "string" && candidate.path.length > 0 - && typeof candidate.summary === "string" && candidate.summary.length > 0 - && ["patch", "minor", "major"].includes(candidate.bump ?? ""), + typeof candidate?.path === "string" && + candidate.path.length > 0 && + typeof candidate.summary === "string" && + candidate.summary.length > 0 && + ["patch", "minor", "major"].includes(candidate.bump ?? ""), "every planned Changeset has a path, bump, and summary", receipt, - ); + ) } - return value as Changeset[]; + return value as Changeset[] } export function runReleaseYield(ctx: ReleaseContext) { const mode = ctx.askUser("select-mode", "Choose how far this Yield release run may proceed.", [ { value: "dry-run", label: "Dry run only" }, { value: "release", label: "Prepare release" }, - ]) as ReleaseMode; + ]) as ReleaseMode 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; + ]) as ReleaseBump if (bump === "minor" || bump === "major") { const confirmation = ctx.askUser( @@ -86,24 +107,53 @@ export function runReleaseYield(ctx: ReleaseContext) { { value: "confirm", label: `Confirm ${bump}` }, { value: "cancel", label: "Cancel" }, ], - ); - if (confirmation !== "confirm") ctx.refused(`${bump} release intent was not confirmed`); + ) + if (confirmation !== "confirm") ctx.refused(`${bump} release intent was not confirmed`) } - 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 preflight = command( + ctx, + "preflight", + `preflight --bump ${bump}`, + "the protected main preflight passes", + ) + const sourceSha = matchingField(ctx, preflight, "source_sha", /^[0-9a-f]{40}$/) - 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+$/); - const changesets = changesetsField(ctx, plan); - 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 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+$/) + const changesets = changesetsField(ctx, plan) + 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, + ) if (mode === "dry-run") { return { @@ -114,10 +164,12 @@ export function runReleaseYield(ctx: ReleaseContext) { source_sha: sourceSha, changesets, dry_run: { id: dryRunID, url: dry.run_url }, - }; + } } - const changesetSummary = changesets.map((item) => `${item.path} (${item.bump}): ${item.summary}`).join("; "); + const changesetSummary = changesets + .map((item) => `${item.path} (${item.bump}): ${item.summary}`) + .join("; ") const authorization = ctx.askUser( "authorize-release", `Dry run passed for ${tag} from ${sourceSha}. Changesets: ${changesetSummary}. Continue with the protected release?`, @@ -125,14 +177,33 @@ export function runReleaseYield(ctx: ReleaseContext) { { value: "release", label: `Release ${tag}` }, { value: "stop", label: "Stop" }, ], - ); - if (authorization !== "release") ctx.refused(`release of ${tag} was not authorized`); + ) + 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 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, @@ -140,8 +211,8 @@ export function runReleaseYield(ctx: ReleaseContext) { `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+$/); + ) + const publisherRunID = matchingField(ctx, release, "publisher_run_id", /^\d+$/) command( ctx, @@ -149,7 +220,7 @@ export function runReleaseYield(ctx: ReleaseContext) { `monitor-publisher --run-id ${publisherRunID}`, "npm, PyPI, and crates.io publishers complete", 3600, - ); + ) const finalized = command( ctx, @@ -157,8 +228,8 @@ export function runReleaseYield(ctx: ReleaseContext) { `monitor-finalizer --baseline ${finalizerBaseline}`, "the release finalizer completes", 1800, - ); - const finalizerRunID = matchingField(ctx, finalized, "run_id", /^\d+$/); + ) + const finalizerRunID = matchingField(ctx, finalized, "run_id", /^\d+$/) const verified = command( ctx, @@ -166,7 +237,7 @@ export function runReleaseYield(ctx: ReleaseContext) { `verify --version ${version} --tag ${tag} --source-sha ${sourceSha}`, "every public release target matches the authorized release", 1800, - ); + ) return { mode: "release", @@ -180,5 +251,5 @@ export function runReleaseYield(ctx: ReleaseContext) { publisher: { id: publisherRunID, url: release.publisher_run_url }, finalizer: { id: finalizerRunID, url: finalized.run_url }, verified: verified.targets, - }; + } }