Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/release-yield/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: release-yield
description: "Release Yield through its protected GitHub workflows and verify every public registry."
---

<!-- generated-by: yskill; source: skills/release-yield; digest: sha256:28f0b916efb30d4a8be4d1b217c475e31b567942c0a7cf1d8c6d912de479477a; version: 0.1.38 -->
<!-- generated-by: yskill; source: skills/release-yield; digest: sha256:7d0b9506af78606920322c52131a716ff50e37826703a07baedaafd19a1f7992; version: 0.1.38 -->

This adapter exposes the canonical Yield workflow at `skills/release-yield`.
Read its SKILL.md, then run from the repository root:
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/release-yield/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: release-yield
description: "Release Yield through its protected GitHub workflows and verify every public registry."
---

<!-- generated-by: yskill; source: skills/release-yield; digest: sha256:28f0b916efb30d4a8be4d1b217c475e31b567942c0a7cf1d8c6d912de479477a; version: 0.1.38 -->
<!-- generated-by: yskill; source: skills/release-yield; digest: sha256:7d0b9506af78606920322c52131a716ff50e37826703a07baedaafd19a1f7992; version: 0.1.38 -->

This adapter exposes the canonical Yield workflow at `skills/release-yield`.
Read its SKILL.md, then run from the repository root:
Expand Down
2 changes: 1 addition & 1 deletion .cursor/skills/release-yield/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: release-yield
description: "Release Yield through its protected GitHub workflows and verify every public registry."
---

<!-- generated-by: yskill; source: skills/release-yield; digest: sha256:28f0b916efb30d4a8be4d1b217c475e31b567942c0a7cf1d8c6d912de479477a; version: 0.1.38 -->
<!-- generated-by: yskill; source: skills/release-yield; digest: sha256:7d0b9506af78606920322c52131a716ff50e37826703a07baedaafd19a1f7992; version: 0.1.38 -->

This adapter exposes the canonical Yield workflow at `skills/release-yield`.
Read its SKILL.md, then run from the repository root:
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<!-- npm-exclude:end -->
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 10 additions & 3 deletions skills/release-yield/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion skills/release-yield/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { defineSkill } from "@operatorstack/yield";
import { runReleaseYield } from "./workflow.ts";
import { runReleaseYield } from "./src/workflow.ts";

defineSkill(runReleaseYield);
Original file line number Diff line number Diff line change
@@ -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" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);
Expand All @@ -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");
Expand All @@ -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);
});
Expand All @@ -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);
});
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,7 +17,7 @@ type Receipt = {

type ReleaseContext = Pick<Context, "askUser" | "runCommand" | "require" | "blocked" | "refused">;

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);
Expand Down Expand Up @@ -42,14 +49,47 @@ 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<Changeset>;
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" },
{ value: "minor", label: "Minor" },
{ 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}$/);

Expand All @@ -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");
Expand Down Expand Up @@ -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 },
Expand Down