diff --git a/.agents/skills/release-yield/SKILL.md b/.agents/skills/release-yield/SKILL.md index 246d8e2..f8dd746 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 246d8e2..f8dd746 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 246d8e2..f8dd746 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/.github/workflows/release.yml b/.github/workflows/release.yml index 72a5b82..72af84f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,12 @@ on: type: boolean default: false required: true + notes_source: + description: Source for the immutable GitHub release notes + type: choice + options: [changesets, git-history] + default: changesets + required: true permissions: contents: read @@ -61,9 +67,11 @@ jobs: name: Resolve immutable release intent env: REQUESTED_BUMP: ${{ inputs.bump }} + NOTES_SOURCE: ${{ inputs.notes_source }} run: >- node scripts/release-plan.mjs --bump "$REQUESTED_BUMP" + --notes-source "$NOTES_SOURCE" --output "$GITHUB_OUTPUT" --notes "$RUNNER_TEMP/release-notes.md" - name: Report dry run @@ -98,9 +106,11 @@ jobs: name: Recompute release intent after approval env: REQUESTED_BUMP: ${{ inputs.bump }} + NOTES_SOURCE: ${{ inputs.notes_source }} run: >- node scripts/release-plan.mjs --bump "$REQUESTED_BUMP" + --notes-source "$NOTES_SOURCE" --output "$GITHUB_OUTPUT" --notes "$RUNNER_TEMP/release-notes.md" - name: Refuse plan drift diff --git a/scripts/readme.test.mjs b/scripts/readme.test.mjs index f9dbf2a..ecc6da1 100644 --- a/scripts/readme.test.mjs +++ b/scripts/readme.test.mjs @@ -413,3 +413,11 @@ test("root README links survive npm package rendering", async () => { assert.doesNotMatch(readme, /\]\((?!https?:\/\/|#|mailto:)[^)]+\)/) assert.doesNotMatch(readme, /href="(?!https?:\/\/|#|mailto:)[^"]+"/) }) + +test("skills index explains canonical interactive workflows", async () => { + const skills = await text("skills/README.md") + assert.match(skills, /canonical Yield workflows/) + assert.match(skills, /A skill is executable workflow source, not a copied prompt\./) + assert.match(skills, /yskill run skills\//) + assert.match(skills, /release-yield.*protected full-train Yield/s) +}) diff --git a/scripts/release-plan.mjs b/scripts/release-plan.mjs index 5ddda65..069a019 100644 --- a/scripts/release-plan.mjs +++ b/scripts/release-plan.mjs @@ -8,6 +8,8 @@ import { parse as parseYaml } from "yaml" const PACKAGE = "@operatorstack/yield" const levels = { patch: 0, minor: 1, major: 2 } +const noteSources = new Set(["changesets", "git-history"]) +const targetContract = "full-train" function parseArgs(argv) { const result = {} @@ -49,24 +51,61 @@ export function bumpVersion(version, bump) { 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") +export function planRelease({ + baseVersion, + changesets, + requestedBump = "auto", + notesSource, + commits = [], +}) { 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]) { + if (requestedBump === "auto" && !declaredBump) + throw new Error("automatic releases require at least one pending Changeset") + if (requestedBump !== "auto" && declaredBump && levels[requestedBump] < levels[declaredBump]) { throw new Error(`requested ${requestedBump} cannot lower declared ${declaredBump}`) } + if (requestedBump !== "auto" && !commits.length) + throw new Error("explicit stable releases require at least one commit after the base tag") const bump = requestedBump === "auto" ? declaredBump : requestedBump - return { baseVersion, bump, version: bumpVersion(baseVersion, bump), changesets } + const basis = requestedBump === "auto" ? "changesets" : "explicit-bump" + const resolvedNotesSource = notesSource ?? (changesets.length ? "changesets" : "git-history") + if (!noteSources.has(resolvedNotesSource)) + throw new Error(`invalid release note source ${resolvedNotesSource ?? "missing"}`) + if (resolvedNotesSource === "changesets" && !changesets.length) + throw new Error("Changeset notes require at least one pending Changeset") + return { + baseVersion, + bump, + version: bumpVersion(baseVersion, bump), + basis, + notesSource: resolvedNotesSource, + changesets, + commits, + } } -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}`]) +export function releaseNotes(plan, baseTag) { + const heading = `# Yield ${plan.version}` + if (plan.notesSource === "changesets") { + return ( + [heading, "", ...plan.changesets.flatMap(({ summary }) => [`- ${summary}`, ""])] + .join("\n") + .trimEnd() + "\n" + ) + } + return [ + heading, + "", + `## Changes since ${baseTag}`, + "", + ...plan.commits.map(({ sha, summary }) => `- ${summary} (${sha.slice(0, 12)})`), + "", + ].join("\n") +} + +async function collectReleaseInput(baseTag) { const paths = git([ "diff", "--name-only", @@ -79,16 +118,45 @@ async function main() { .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" + const commits = git(["log", "--format=%H%x09%s", `${baseTag}..HEAD`]) + .split("\n") + .filter(Boolean) + .map((line) => { + const [sha, summary] = line.split("\t", 2) + if (!/^[0-9a-f]{40}$/.test(sha) || !summary) throw new Error("invalid release history") + return { sha, summary } + }) + return { + baseTag, + sourceSha, + changesets, + commits, + commitRange: `${baseTag}..${sourceSha}`, + targetContract, + } +} + +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 input = await collectReleaseInput(baseTag) + if (args.inspect === "true") { + process.stdout.write(`${JSON.stringify(input, null, 2)}\n`) + return + } + const plan = planRelease({ + baseVersion: baseTag.slice(1), + changesets: input.changesets, + requestedBump, + notesSource: args["notes-source"], + commits: input.commits, + }) + const notes = releaseNotes(plan, baseTag) + const output = { ...plan, ...input } if (args.notes) await writeFile(args.notes, notes) if (args.output) { await writeFile( @@ -98,14 +166,18 @@ async function main() { `bump=${plan.bump}`, `version=${plan.version}`, `tag=v${plan.version}`, - `source_sha=${sourceSha}`, + `source_sha=${input.sourceSha}`, `changeset_count=${plan.changesets.length}`, + `release_basis=${plan.basis}`, + `notes_source=${plan.notesSource}`, + `commit_range=${input.commitRange}`, + `target_contract=${input.targetContract}`, "", ].join("\n"), { flag: "a" }, ) } - process.stdout.write(`${JSON.stringify({ ...plan, baseTag, sourceSha }, null, 2)}\n`) + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`) } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { diff --git a/scripts/release-plan.test.mjs b/scripts/release-plan.test.mjs index c115805..9d0b5f3 100644 --- a/scripts/release-plan.test.mjs +++ b/scripts/release-plan.test.mjs @@ -1,22 +1,30 @@ import test from "node:test" import assert from "node:assert/strict" -import { bumpVersion, parseChangeset, planRelease } from "./release-plan.mjs" +import { bumpVersion, parseChangeset, planRelease, releaseNotes } from "./release-plan.mjs" +const commits = [{ sha: "a".repeat(40), summary: "Ship a deterministic release plan" }] 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", - ) + const plan = planRelease({ + baseVersion: "0.1.29", + changesets: [changeset("patch"), changeset("minor")], + commits, + }) + assert.equal(plan.version, "0.2.0") + assert.equal(plan.basis, "changesets") + assert.equal(plan.notesSource, "changesets") }) -test("allows an explicit bump to raise but not lower intent", () => { +test("allows an explicit bump to raise but not lower pending Changeset intent", () => { assert.equal( - planRelease({ baseVersion: "0.1.29", changesets: [changeset("patch")], requestedBump: "major" }) - .version, + planRelease({ + baseVersion: "0.1.29", + changesets: [changeset("patch")], + requestedBump: "major", + commits, + }).version, "1.0.0", ) assert.throws( @@ -25,23 +33,56 @@ test("allows an explicit bump to raise but not lower intent", () => { baseVersion: "0.1.29", changesets: [changeset("major")], requestedBump: "minor", + commits, }), /cannot lower/, ) }) -test("requires pending release intent", () => { - assert.throws(() => planRelease({ baseVersion: "0.1.29", changesets: [] }), /at least one/) +test("permits an explicit release without Changesets and uses Git history", () => { + const plan = planRelease({ + baseVersion: "0.1.29", + changesets: [], + requestedBump: "minor", + commits, + }) + assert.equal(plan.version, "0.2.0") + assert.equal(plan.basis, "explicit-bump") + assert.equal(plan.notesSource, "git-history") + assert.equal( + releaseNotes(plan, "v0.1.29"), + "# Yield 0.2.0\n\n## Changes since v0.1.29\n\n- Ship a deterministic release plan (aaaaaaaaaaaa)\n", + ) +}) + +test("automatic releases still require Changesets", () => { + assert.throws( + () => planRelease({ baseVersion: "0.1.29", changesets: [], commits }), + /automatic releases require at least one pending Changeset/, + ) }) -test("rejects another package or malformed bump", () => { +test("refuses an explicit release with no new commit and Changeset notes without Changesets", () => { assert.throws( - () => parseChangeset(`---\nother: patch\n---\n\nNo\n`), - /only @operatorstack\/yield/, + () => + planRelease({ + baseVersion: "0.1.29", + changesets: [], + requestedBump: "patch", + commits: [], + }), + /at least one commit/, ) assert.throws( - () => parseChangeset(`---\n"@operatorstack/yield": huge\n---\n\nNo\n`), - /patch, minor, or major/, + () => + planRelease({ + baseVersion: "0.1.29", + changesets: [], + requestedBump: "patch", + notesSource: "changesets", + commits, + }), + /Changeset notes require/, ) }) diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 0000000..c5dc4fe --- /dev/null +++ b/skills/README.md @@ -0,0 +1,24 @@ +# Project skills + +This directory contains the canonical Yield workflows shipped with this +repository. A skill is executable workflow source, not a copied prompt. +Generated Codex, Cursor, and Claude Code adapters point back here. + +Run a skill from the repository root: + +```sh +npm exec -- yskill run skills/ +``` + +Use `yskill doctor skills/` to check a skill, and +`yskill register skills/ --agent cursor,codex,claude-code` to make +it discoverable by coding agents. Add `--test` only when the workflow supplies +safe fixture responses. + +## Included workflows + +- [`release-yield`](release-yield/) guides a protected full-train Yield + release. It asks for release choices, verifies the exact plan, and leaves + registry credentials and publication to GitHub's protected workflows. Its + safe contract tests run with `npm run test:selfhost`; do not fixture-run a + workflow that can dispatch a protected release. diff --git a/skills/release-yield/SKILL.md b/skills/release-yield/SKILL.md index e7738b2..a4e2077 100644 --- a/skills/release-yield/SKILL.md +++ b/skills/release-yield/SKILL.md @@ -9,19 +9,29 @@ 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 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. - -Dry-run-only returns the version, tag, source SHA, Changesets, and workflow URL, -then stops before tags, approvals, or publication. +Follow every returned operation exactly. The workflow asks the operator to +choose release mode, scope, version basis, bump, and release-note source. +Choosing **Stop** at any decision ends the run before the next protected action. + +Stable releases currently use the **full Yield train**: one version and source +SHA across npm, PyPI, crates.io, Go, the GitHub tag, and release evidence. +Target-specific publication is not available until it has a compatibility +manifest and verification path; the workflow explains this and stops rather +than guessing a partial release. + +Choose **Use the Changeset bump** when pending Changesets describe the release. +Choose an explicit patch, minor, or major bump when the operator wants to +release the exact Git history without a Changeset. With no Changesets, the +workflow offers an explicit bump and deterministic Git-history release notes; +it does not treat this as an npm failure. An explicit release must contain a +commit after the base tag and cannot lower a pending Changeset bump. + +Choose **Dry run only** to resolve and verify the immutable plan without +publishing. Choose **Prepare release** to continue to a second authorization +for the exact version, source SHA, note source, and target contract after the +protected dry run succeeds. Minor and major choices require confirmation before +preflight or GitHub workflow dispatch. + +The workflow records authorization for the exact plan, then asks GitHub to +enforce protected environments. It never publishes packages, creates tags, or +handles registry credentials locally. diff --git a/skills/release-yield/src/release-controller.mjs b/skills/release-yield/src/release-controller.mjs index cda1f8d..12e1bd8 100644 --- a/skills/release-yield/src/release-controller.mjs +++ b/skills/release-yield/src/release-controller.mjs @@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url" const repository = "operatorstack/yield" const root = resolve(import.meta.dirname, "../../..") const bumps = new Set(["auto", "patch", "minor", "major"]) +const noteSources = new Set(["changesets", "git-history"]) const active = new Set(["queued", "in_progress", "pending", "waiting", "requested"]) const npmPackages = [ "@operatorstack/yield", @@ -109,6 +110,11 @@ function requireBump(value) { return value } +function requireNotesSource(value) { + if (!noteSources.has(value)) throw new Failed(`invalid release note source ${value ?? "missing"}`) + return value +} + function normalizeRemote(value) { return value.replace(/^git@github\.com:/, "https://github.com/").replace(/\.git$/, "") } @@ -203,6 +209,7 @@ async function preflight(values) { async function dispatch(values) { const bump = requireBump(values.bump) + const notesSource = requireNotesSource(values["notes-source"]) 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") @@ -231,6 +238,8 @@ async function dispatch(values) { `bump=${bump}`, "-f", `dry_run=${values["dry-run"]}`, + "-f", + `notes_source=${notesSource}`, ) } catch { throw new Blocked("GitHub refused the release workflow dispatch") @@ -248,14 +257,27 @@ async function dispatch(values) { } } +async function inspect() { + return JSON.parse(run("node", ["scripts/release-plan.mjs", "--inspect", "true"])) +} + async function plan(values) { const bump = requireBump(values.bump) - const value = JSON.parse(run("node", ["scripts/release-plan.mjs", "--bump", bump])) + const notesSource = requireNotesSource(values["notes-source"]) + const value = JSON.parse( + run("node", ["scripts/release-plan.mjs", "--bump", bump, "--notes-source", notesSource]), + ) return { version: value.version, tag: `v${value.version}`, source_sha: value.sourceSha, changesets: value.changesets, + release_basis: value.basis, + notes_source: value.notesSource, + base_tag: value.baseTag, + commit_range: value.commitRange, + commit_count: value.commits.length, + target_contract: value.targetContract, } } @@ -358,6 +380,7 @@ async function verify(values) { export async function execute(action, values) { if (action === "preflight") return preflight(values) + if (action === "inspect") return inspect() if (action === "dispatch") return dispatch(values) if (action === "wait") { const info = await waitRun(values["run-id"]) diff --git a/skills/release-yield/src/workflow.test.mjs b/skills/release-yield/src/workflow.test.mjs index cee9ff7..e0dcd75 100644 --- a/skills/release-yield/src/workflow.test.mjs +++ b/skills/release-yield/src/workflow.test.mjs @@ -3,9 +3,45 @@ import assert from "node:assert/strict" import { runReleaseYield } from "./workflow.ts" const sha = "a".repeat(40) +const baseTag = "v1.2.2" +const commitRange = `${baseTag}..${sha}` +const changesets = [ + { path: ".changeset/example.md", bump: "patch", summary: "Improve release confirmation." }, +] + +function inspection(overrides = {}) { + return { + status: "ok", + baseTag, + sourceSha: sha, + commitRange, + targetContract: "full-train", + commits: [{ sha, summary: "Improve release confirmation" }], + changesets, + ...overrides, + } +} + +function plan(overrides = {}) { + return { + status: "ok", + source_sha: sha, + version: "1.2.3", + tag: "v1.2.3", + changesets, + release_basis: "changesets", + notes_source: "changesets", + base_tag: baseTag, + commit_range: commitRange, + commit_count: 1, + target_contract: "full-train", + ...overrides, + } +} function successReceipts(overrides = {}) { return { + "inspect-release-input": inspection(), preflight: { status: "ok", source_sha: sha }, "dispatch-dry-run": { status: "ok", @@ -19,15 +55,7 @@ function successReceipts(overrides = {}) { 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." }, - ], - }, + "resolve-plan": plan(), "dispatch-release": { status: "ok", source_sha: sha, @@ -58,25 +86,28 @@ function successReceipts(overrides = {}) { } } -function context({ - mode = "release", - bump = "patch", - confirmation = "confirm", - authorization = "release", - receipts = successReceipts(), -} = {}) { +function context({ mode = "release", choices = {}, receipts = successReceipts() } = {}) { const operations = [] + const commands = [] + const defaults = { + "select-mode": mode, + "select-release-scope": "full-train", + "select-version-basis": "changesets", + "select-note-source": "changesets", + "authorize-release": "release", + } return { operations, + commands, 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 + const value = choices[id] ?? defaults[id] + assert.ok(value, `missing choice for ${id}`) + return value }, - runCommand(id) { + runCommand(id, command) { operations.push(id) + commands.push({ id, command }) const receipt = receipts[id] assert.ok(receipt, `missing receipt for ${id}`) return { exit_code: 0, stdout: JSON.stringify(receipt), stderr: "" } @@ -93,12 +124,15 @@ function context({ } } -test("enforces dry run, immutable authorization, protected publication, and verification order", () => { +test("enforces the full decision tree, immutable authorization, and registry verification order", () => { const ctx = context() const result = runReleaseYield(ctx) assert.deepEqual(ctx.operations, [ "select-mode", - "select-bump", + "select-release-scope", + "inspect-release-input", + "select-version-basis", + "select-note-source", "preflight", "dispatch-dry-run", "wait-dry-run", @@ -111,56 +145,120 @@ test("enforces dry run, immutable authorization, protected publication, and veri "verify-public-release", ]) assert.equal(result.version, "1.2.3") - assert.equal(result.source_sha, sha) + assert.equal(result.release_basis, "changesets") assert.equal(result.verified.npm, 8) + assert.match( + ctx.commands.find(({ id }) => id === "dispatch-dry-run").command, + /--notes-source changesets/, + ) }) -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/) +test("uses an explicit bump and Git history when no Changesets are pending", () => { + const noChangesets = inspection({ changesets: [] }) + const explicitPlan = plan({ + changesets: [], + release_basis: "explicit-bump", + notes_source: "git-history", + }) + const ctx = context({ + mode: "dry-run", + choices: { + "handle-no-changesets": "explicit", + "select-explicit-bump": "patch", + "confirm-git-history-notes": "git-history", + }, + receipts: successReceipts({ + "inspect-release-input": noChangesets, + "resolve-plan": explicitPlan, + }), + }) + const result = runReleaseYield(ctx) + assert.equal(result.release_basis, "explicit-bump") + assert.equal(result.note_source, "git-history") + assert.match( + ctx.commands.find(({ id }) => id === "dispatch-dry-run").command, + /--notes-source git-history/, + ) + assert.equal(ctx.operations.includes("authorize-release"), false) assert.equal(ctx.operations.includes("dispatch-release"), false) }) -test("dry-run-only completes after the verified plan without asking for release authorization", () => { +test("confirms an explicit minor before preflight", () => { + const ctx = context({ + mode: "dry-run", + choices: { + "select-version-basis": "explicit", + "select-explicit-bump": "minor", + "confirm-high-impact-bump": "confirm", + "select-note-source": "git-history", + }, + receipts: successReceipts({ + "resolve-plan": plan({ release_basis: "explicit-bump", notes_source: "git-history" }), + }), + }) + runReleaseYield(ctx) + assert.deepEqual(ctx.operations.slice(0, 7), [ + "select-mode", + "select-release-scope", + "inspect-release-input", + "select-version-basis", + "select-explicit-bump", + "select-note-source", + "confirm-high-impact-bump", + ]) +}) + +test("stops before GitHub activity when the operator declines any early decision", () => { + for (const [label, config] of [ + ["mode", { mode: "stop" }], + ["scope", { choices: { "select-release-scope": "design-target-specific" } }], + [ + "no Changesets", + { + choices: { "handle-no-changesets": "stop" }, + receipts: successReceipts({ "inspect-release-input": inspection({ changesets: [] }) }), + }, + ], + ["notes", { choices: { "select-note-source": "stop" } }], + ]) { + const ctx = context(config) + assert.throws(() => runReleaseYield(ctx), /refused:/, label) + assert.equal(ctx.operations.includes("preflight"), false, label) + assert.equal(ctx.operations.includes("dispatch-dry-run"), false, label) + } +}) + +test("dry-run returns the selected full-train plan without live 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(result.target_contract, "full-train") 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("refuses plan drift before authorization", () => { + const ctx = context({ + receipts: successReceipts({ "resolve-plan": plan({ source_sha: "b".repeat(40) }) }), + }) + assert.throws(() => runReleaseYield(ctx), /displayed plan uses the dry-run source SHA/) + assert.equal(ctx.operations.includes("authorize-release"), 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("refuses a non-full-train plan before authorization", () => { + const ctx = context({ + receipts: successReceipts({ "resolve-plan": plan({ target_contract: "npm-only" }) }), + }) + assert.throws(() => runReleaseYield(ctx), /valid target_contract/) + assert.equal(ctx.operations.includes("authorize-release"), false) }) -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("refuses a Changeset-basis plan without Changesets", () => { + const ctx = context({ + receipts: successReceipts({ "resolve-plan": plan({ changesets: [] }) }), + }) + assert.throws(() => runReleaseYield(ctx), /Changeset-based plan contains pending Changesets/) + assert.equal(ctx.operations.includes("authorize-release"), false) }) test("reports a GitHub authority boundary as blocked", () => { @@ -170,29 +268,6 @@ test("reports a GitHub authority boundary as blocked", () => { }), }) 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) }) test("rejects malformed controller receipts", () => { @@ -203,32 +278,3 @@ 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/src/workflow.ts b/skills/release-yield/src/workflow.ts index 27da557..8b7708f 100644 --- a/skills/release-yield/src/workflow.ts +++ b/skills/release-yield/src/workflow.ts @@ -1,7 +1,8 @@ import type { CommandResult, Context } from "@operatorstack/yield" export type ReleaseBump = "auto" | "patch" | "minor" | "major" -export type ReleaseMode = "dry-run" | "release" +export type ReleaseMode = "dry-run" | "release" | "stop" +export type NoteSource = "changesets" | "git-history" type Changeset = { bump: "patch" | "minor" | "major" @@ -18,6 +19,7 @@ type Receipt = { type ReleaseContext = Pick const controller = "node src/release-controller.mjs" +const fullTrain = "full-train" function parseReceipt(ctx: ReleaseContext, claim: string, result: CommandResult): Receipt { ctx.require(result.exit_code === 0 && !result.timed_out, claim, result) @@ -64,13 +66,19 @@ function matchingField( return value } -function changesetsField(ctx: ReleaseContext, receipt: Receipt): Changeset[] { - const value = receipt.changesets +function countField(ctx: ReleaseContext, receipt: Receipt, field: string): number { + const value = receipt[field] ctx.require( - Array.isArray(value) && value.length > 0, - "the release plan contains at least one Changeset", + Number.isInteger(value) && (value as number) >= 0, + `controller receipt contains a non-negative ${field}`, receipt, ) + return value as number +} + +function changesetsField(ctx: ReleaseContext, receipt: Receipt): Changeset[] { + const value = receipt.changesets + ctx.require(Array.isArray(value), "controller receipt contains a Changeset list", receipt) for (const item of value as unknown[]) { const candidate = item as Partial ctx.require( @@ -79,38 +87,125 @@ function changesetsField(ctx: ReleaseContext, receipt: Receipt): Changeset[] { typeof candidate.summary === "string" && candidate.summary.length > 0 && ["patch", "minor", "major"].includes(candidate.bump ?? ""), - "every planned Changeset has a path, bump, and summary", + "every listed Changeset has a path, bump, and summary", receipt, ) } return value as Changeset[] } +function explicitBump(ctx: ReleaseContext): Exclude { + const choice = ctx.askUser("select-explicit-bump", "Choose the explicit Yield release bump.", [ + { value: "patch", label: "Patch" }, + { value: "minor", label: "Minor" }, + { value: "major", label: "Major" }, + { value: "stop", label: "Stop" }, + ]) + if (choice === "stop") ctx.refused("release was stopped before preflight") + ctx.require(["patch", "minor", "major"].includes(choice), "an explicit release bump is selected") + return choice as Exclude +} + +function confirmHighImpactBump(ctx: ReleaseContext, bump: ReleaseBump) { + if (bump !== "minor" && bump !== "major") return + 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`) +} + 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" }, + { value: "stop", label: "Stop" }, ]) as ReleaseMode + if (mode === "stop") ctx.refused("release was not started") - const bump = ctx.askUser("select-bump", "Choose the Yield release bump.", [ - { value: "auto", label: "Use Changesets" }, - { value: "patch", label: "Patch" }, - { value: "minor", label: "Minor" }, - { value: "major", label: "Major" }, - ]) as ReleaseBump + const scope = ctx.askUser( + "select-release-scope", + "Stable releases currently publish one verified Yield version to every public target.", + [ + { value: fullTrain, label: "Release the full Yield train" }, + { value: "design-target-specific", label: "Stop and design target-specific releases" }, + { value: "stop", label: "Stop" }, + ], + ) + if (scope === "design-target-specific") + ctx.refused( + "target-specific stable releases need a compatibility manifest and verification path", + ) + if (scope === "stop") ctx.refused("release was stopped before input inspection") + ctx.require(scope === fullTrain, "the selected stable release scope is the full Yield train") + + const input = command( + ctx, + "inspect-release-input", + "inspect", + "the release input is inspected before a decision", + ) + const inputChangesets = changesetsField(ctx, input) - 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.`, + let bump: ReleaseBump + let notesSource: NoteSource + if (inputChangesets.length) { + const basis = ctx.askUser( + "select-version-basis", + `Found ${inputChangesets.length} pending Changeset(s). Choose how to set the release version.`, + [ + { value: "changesets", label: "Use the Changeset bump" }, + { value: "explicit", label: "Select an explicit bump" }, + { value: "stop", label: "Stop" }, + ], + ) + if (basis === "stop") ctx.refused("release was stopped before bump selection") + bump = basis === "changesets" ? "auto" : explicitBump(ctx) + const noteChoice = ctx.askUser( + "select-note-source", + "Choose the source for immutable GitHub release notes.", + [ + { value: "changesets", label: "Use Changeset summaries" }, + { value: "git-history", label: "Use Git history" }, + { value: "stop", label: "Stop" }, + ], + ) + if (noteChoice === "stop") ctx.refused("release was stopped before note selection") + ctx.require( + noteChoice === "changesets" || noteChoice === "git-history", + "a release note source is selected", + ) + notesSource = noteChoice as NoteSource + } else { + const noChangesets = ctx.askUser( + "handle-no-changesets", + "No pending Changesets were found. Select an explicit bump to release from the exact Git history, or stop.", + [ + { value: "explicit", label: "Select an explicit bump" }, + { value: "stop", label: "Stop" }, + ], + ) + if (noChangesets !== "explicit") + ctx.refused("release was stopped because no Changeset was selected") + bump = explicitBump(ctx) + const historyNotes = ctx.askUser( + "confirm-git-history-notes", + "Generate immutable release notes from the exact base-tag-to-HEAD Git history?", [ - { value: "confirm", label: `Confirm ${bump}` }, - { value: "cancel", label: "Cancel" }, + { value: "git-history", label: "Generate Git-history notes" }, + { value: "stop", label: "Stop" }, ], ) - if (confirmation !== "confirm") ctx.refused(`${bump} release intent was not confirmed`) + if (historyNotes !== "git-history") + ctx.refused("release was stopped before Git-history notes were selected") + notesSource = "git-history" } + confirmHighImpactBump(ctx, bump) const preflight = command( ctx, "preflight", @@ -122,7 +217,7 @@ export function runReleaseYield(ctx: ReleaseContext) { const dry = command( ctx, "dispatch-dry-run", - `dispatch --bump ${bump} --dry-run true`, + `dispatch --bump ${bump} --notes-source ${notesSource} --dry-run true`, "the dry-run workflow is dispatched", ) const dryRunID = matchingField(ctx, dry, "run_id", /^\d+$/) @@ -142,23 +237,53 @@ export function runReleaseYield(ctx: ReleaseContext) { const plan = command( ctx, "resolve-plan", - `plan --bump ${bump}`, + `plan --bump ${bump} --notes-source ${notesSource}`, "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) + const releaseBasis = matchingField(ctx, plan, "release_basis", /^(changesets|explicit-bump)$/) + const baseTag = matchingField(ctx, plan, "base_tag", /^v\d+\.\d+\.\d+$/) + const commitRange = stringField(ctx, plan, "commit_range") + const commitCount = countField(ctx, plan, "commit_count") + const planNotesSource = matchingField(ctx, plan, "notes_source", /^(changesets|git-history)$/) + const targetContract = matchingField(ctx, plan, "target_contract", /^full-train$/) 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, ) + ctx.require( + planNotesSource === notesSource, + "the displayed plan uses the selected note source", + plan, + ) + ctx.require(targetContract === fullTrain, "the displayed plan keeps the full Yield train", plan) + ctx.require( + commitRange === `${baseTag}..${sourceSha}`, + "the plan binds the exact Git history range", + plan, + ) + if (releaseBasis === "changesets") + ctx.require(changesets.length > 0, "a Changeset-based plan contains pending Changesets", plan) + if (releaseBasis === "explicit-bump") + ctx.require(commitCount > 0, "an explicit release contains commits after the base tag", plan) + const releaseSummary = + planNotesSource === "changesets" + ? `${changesets.length} Changeset(s)` + : `Git history ${commitRange} (${commitCount} commits)` if (mode === "dry-run") { return { mode, bump, + release_basis: releaseBasis, + note_source: planNotesSource, + base_tag: baseTag, + commit_range: commitRange, + target_contract: targetContract, version, tag, source_sha: sourceSha, @@ -167,12 +292,9 @@ export function runReleaseYield(ctx: ReleaseContext) { } } - 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?`, + `Dry run passed for ${tag} from ${sourceSha}. Basis: ${releaseBasis}; notes: ${releaseSummary}; targets: full Yield train. Continue with the protected release?`, [ { value: "release", label: `Release ${tag}` }, { value: "stop", label: "Stop" }, @@ -183,7 +305,7 @@ export function runReleaseYield(ctx: ReleaseContext) { const live = command( ctx, "dispatch-release", - `dispatch --bump ${bump} --dry-run false`, + `dispatch --bump ${bump} --notes-source ${notesSource} --dry-run false`, "the protected release workflow is dispatched", ) ctx.require( @@ -242,6 +364,11 @@ export function runReleaseYield(ctx: ReleaseContext) { return { mode: "release", bump, + release_basis: releaseBasis, + note_source: planNotesSource, + base_tag: baseTag, + commit_range: commitRange, + target_contract: targetContract, version, tag, source_sha: sourceSha,