From d6fa54b8e2ce26f2a4e198afc0f66e1dda9ec8af Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sat, 8 Aug 2026 09:48:55 +0100 Subject: [PATCH] Improve release-yield confirmation flow --- .agents/skills/release-yield/SKILL.md | 2 +- .claude/skills/release-yield/SKILL.md | 2 +- .cursor/skills/release-yield/SKILL.md | 2 +- README.md | 5 ++ package.json | 2 +- skills/release-yield/SKILL.md | 13 +++- skills/release-yield/main.ts | 2 +- .../{scripts => src}/release-controller.mjs | 0 .../release-controller.test.mjs} | 2 +- .../release-yield/{ => src}/workflow.test.mjs | 69 ++++++++++++++++--- skills/release-yield/{ => src}/workflow.ts | 68 ++++++++++++++---- 11 files changed, 138 insertions(+), 29 deletions(-) rename skills/release-yield/{scripts => src}/release-controller.mjs (100%) rename skills/release-yield/{controller.test.mjs => src/release-controller.test.mjs} (92%) rename skills/release-yield/{ => src}/workflow.test.mjs (54%) rename skills/release-yield/{ => src}/workflow.ts (73%) diff --git a/.agents/skills/release-yield/SKILL.md b/.agents/skills/release-yield/SKILL.md index 97d5092..3726695 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 97d5092..3726695 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 97d5092..3726695 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/README.md b/README.md index 6c3ed2b..b8c72a9 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,11 @@ authorization, waits through the npm, PyPI, and crates.io environments, and verifies the Go module and final GitHub release. It never publishes from the developer's computer. +The workflow starts by choosing **Dry run only** or **Prepare release**. Minor +and major intent requires a separate confirmation before any GitHub operation. +Dry-run-only returns the immutable version, tag, source SHA, Changesets, and +workflow URL without publishing. + Every newly published canary runs the same contract tests in an isolated CI lane. Stable release execution remains pinned to an exact public version. diff --git a/package.json b/package.json index 5648794..58b97eb 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "prepare:selfhost": "node scripts/prepare-selfhost.mjs", "release:plan": "node scripts/release-plan.mjs", "test:release": "node --test scripts/*.test.mjs packaging/*.test.mjs", - "test:selfhost": "node --test skills/release-yield/*.test.mjs" + "test:selfhost": "node --test skills/release-yield/src/*.test.mjs" }, "devDependencies": { "@operatorstack/yield": "0.1.38", diff --git a/skills/release-yield/SKILL.md b/skills/release-yield/SKILL.md index f5e3d65..e7738b2 100644 --- a/skills/release-yield/SKILL.md +++ b/skills/release-yield/SKILL.md @@ -10,11 +10,18 @@ Run it from the repository root: npm exec -- yskill run skills/release-yield Follow every returned operation exactly. If the user already requested `auto`, -`patch`, `minor`, or `major`, use that value when the first choice appears. +`patch`, `minor`, or `major`, use that value when the bump choice appears. + +Choose **Dry run only** to resolve and verify the immutable release plan without +publishing. Choose **Prepare release** to continue to a second authorization for +the exact version and source SHA after the protected dry run succeeds. + +Minor and major intent must be confirmed before preflight or GitHub workflow +dispatch. Cancelling that confirmation ends the run without GitHub activity. The workflow records one authorization for the exact version and source SHA, then asks GitHub to enforce the repository's protected environments. It never publishes packages, creates tags, or handles registry credentials locally. -Choose **Finish after dry run** at the authorization step to complete with the -verified plan and stop before tags, approvals, or publication. +Dry-run-only returns the version, tag, source SHA, Changesets, and workflow URL, +then stops before tags, approvals, or publication. diff --git a/skills/release-yield/main.ts b/skills/release-yield/main.ts index 10f7d6b..5b3317f 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 "./workflow.ts"; +import { runReleaseYield } from "./src/workflow.ts"; defineSkill(runReleaseYield); diff --git a/skills/release-yield/scripts/release-controller.mjs b/skills/release-yield/src/release-controller.mjs similarity index 100% rename from skills/release-yield/scripts/release-controller.mjs rename to skills/release-yield/src/release-controller.mjs diff --git a/skills/release-yield/controller.test.mjs b/skills/release-yield/src/release-controller.test.mjs similarity index 92% rename from skills/release-yield/controller.test.mjs rename to skills/release-yield/src/release-controller.test.mjs index 8a88f10..e0e85ed 100644 --- a/skills/release-yield/controller.test.mjs +++ b/skills/release-yield/src/release-controller.test.mjs @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { Blocked, selectNewRun } from "./scripts/release-controller.mjs"; +import { Blocked, selectNewRun } from "./release-controller.mjs"; const runs = [ { databaseId: 3, event: "workflow_dispatch", headSha: "abc" }, diff --git a/skills/release-yield/workflow.test.mjs b/skills/release-yield/src/workflow.test.mjs similarity index 54% rename from skills/release-yield/workflow.test.mjs rename to skills/release-yield/src/workflow.test.mjs index 5c489f8..b7c767c 100644 --- a/skills/release-yield/workflow.test.mjs +++ b/skills/release-yield/src/workflow.test.mjs @@ -9,7 +9,10 @@ function successReceipts(overrides = {}) { preflight: { status: "ok", source_sha: sha }, "dispatch-dry-run": { status: "ok", source_sha: sha, run_id: "10", run_url: "https://example.test/10" }, "wait-dry-run": { status: "ok", source_sha: sha, run_id: "10", run_url: "https://example.test/10" }, - "resolve-plan": { status: "ok", source_sha: sha, version: "1.2.3", tag: "v1.2.3" }, + "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." }], + }, "dispatch-release": { status: "ok", source_sha: sha, run_id: "11", run_url: "https://example.test/11", publisher_baseline: "1,2", finalizer_baseline: "3,4", @@ -28,13 +31,16 @@ function successReceipts(overrides = {}) { }; } -function context({ authorization = "release", receipts = successReceipts() } = {}) { +function context({ mode = "release", bump = "patch", confirmation = "confirm", authorization = "release", receipts = successReceipts() } = {}) { const operations = []; return { operations, askUser(id) { operations.push(id); - return id === "select-bump" ? "patch" : authorization; + 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); @@ -58,7 +64,7 @@ test("enforces dry run, immutable authorization, protected publication, and veri const ctx = context(); const result = runReleaseYield(ctx); assert.deepEqual(ctx.operations, [ - "select-bump", "preflight", "dispatch-dry-run", "wait-dry-run", "resolve-plan", "authorize-release", + "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"); @@ -72,22 +78,51 @@ test("stops before live dispatch when authorization is declined", () => { assert.equal(ctx.operations.includes("dispatch-release"), false); }); -test("completes successfully after the verified dry run without dispatching a release", () => { - const ctx = context({ authorization: "dry-run" }); +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); }); +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); + } +}); + +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"]); + } +}); + +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"]); + } +}); + test("reports a GitHub authority boundary as blocked", () => { const ctx = context({ receipts: successReceipts({ preflight: { status: "blocked", reason: "GitHub denied workflow dispatch" } }) }); assert.throws(() => runReleaseYield(ctx), /blocked: GitHub denied workflow dispatch/); - assert.deepEqual(ctx.operations, ["select-bump", "preflight"]); + 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" } }) }); + 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); }); @@ -100,3 +135,21 @@ test("rejects malformed controller receipts", () => { }; assert.throws(() => runReleaseYield(ctx), /controller returned invalid JSON/); }); + +test("rejects a timed-out controller operation", () => { + 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"]); +}); + +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); +}); diff --git a/skills/release-yield/workflow.ts b/skills/release-yield/src/workflow.ts similarity index 73% rename from skills/release-yield/workflow.ts rename to skills/release-yield/src/workflow.ts index 0296a6c..d9ee368 100644 --- a/skills/release-yield/workflow.ts +++ b/skills/release-yield/src/workflow.ts @@ -1,6 +1,13 @@ import type { CommandResult, Context } from "@operatorstack/yield"; export type ReleaseBump = "auto" | "patch" | "minor" | "major"; +export type ReleaseMode = "dry-run" | "release"; + +type Changeset = { + bump: "patch" | "minor" | "major"; + path: string; + summary: string; +}; type Receipt = { status: "ok" | "blocked" | "failed"; @@ -10,7 +17,7 @@ type Receipt = { type ReleaseContext = Pick; -const controller = "node scripts/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); @@ -42,7 +49,28 @@ function matchingField(ctx: ReleaseContext, receipt: Receipt, field: string, pat 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); + for (const item of value as unknown[]) { + 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 ?? ""), + "every planned Changeset has a path, bump, and summary", + receipt, + ); + } + 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; + const bump = ctx.askUser("select-bump", "Choose the Yield release bump.", [ { value: "auto", label: "Use Changesets" }, { value: "patch", label: "Patch" }, @@ -50,6 +78,18 @@ export function runReleaseYield(ctx: ReleaseContext) { { value: "major", label: "Major" }, ]) as ReleaseBump; + if (bump === "minor" || bump === "major") { + const confirmation = ctx.askUser( + "confirm-high-impact-bump", + `Confirm the ${bump} release intent before GitHub performs the protected dry run.`, + [ + { value: "confirm", label: `Confirm ${bump}` }, + { value: "cancel", label: "Cancel" }, + ], + ); + 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}$/); @@ -61,28 +101,31 @@ export function runReleaseYield(ctx: ReleaseContext) { 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 authorization = ctx.askUser( - "authorize-release", - `Dry run passed for ${tag} from ${sourceSha}. Continue with the protected release?`, - [ - { value: "release", label: `Release ${tag}` }, - { value: "dry-run", label: "Finish after dry run" }, - { value: "stop", label: "Stop" }, - ], - ); - if (authorization === "dry-run") { + if (mode === "dry-run") { return { - mode: "dry-run", + mode, bump, version, tag, 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 authorization = ctx.askUser( + "authorize-release", + `Dry run passed for ${tag} from ${sourceSha}. Changesets: ${changesetSummary}. Continue with the protected release?`, + [ + { value: "release", label: `Release ${tag}` }, + { value: "stop", label: "Stop" }, + ], + ); 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"); @@ -131,6 +174,7 @@ export function runReleaseYield(ctx: ReleaseContext) { version, tag, source_sha: sourceSha, + changesets, dry_run: { id: dryRunID, url: dry.run_url }, release_controller: { id: releaseRunID, url: live.run_url }, publisher: { id: publisherRunID, url: release.publisher_run_url },