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:5193de687010c12767ddb81ef93d3d925b769ad46e67bf926d26bb66164a2bf6; version: 0.1.38 -->
<!-- generated-by: yskill; source: skills/release-yield; digest: sha256:d93bea787e01382dad70d63cf2d8519e8ddabe61a0a6144953fb6e356cbfa7df; 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:5193de687010c12767ddb81ef93d3d925b769ad46e67bf926d26bb66164a2bf6; version: 0.1.38 -->
<!-- generated-by: yskill; source: skills/release-yield; digest: sha256:d93bea787e01382dad70d63cf2d8519e8ddabe61a0a6144953fb6e356cbfa7df; 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:5193de687010c12767ddb81ef93d3d925b769ad46e67bf926d26bb66164a2bf6; version: 0.1.38 -->
<!-- generated-by: yskill; source: skills/release-yield; digest: sha256:d93bea787e01382dad70d63cf2d8519e8ddabe61a0a6144953fb6e356cbfa7df; 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
10 changes: 10 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions scripts/readme.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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\/<skill-name>/)
assert.match(skills, /release-yield.*protected full-train Yield/s)
})
114 changes: 93 additions & 21 deletions scripts/release-plan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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",
Expand All @@ -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(
Expand All @@ -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) {
Expand Down
73 changes: 57 additions & 16 deletions scripts/release-plan.test.mjs
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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/,
)
})

Expand Down
24 changes: 24 additions & 0 deletions skills/README.md
Original file line number Diff line number Diff line change
@@ -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/<skill-name>
```

Use `yskill doctor skills/<skill-name>` to check a skill, and
`yskill register skills/<skill-name> --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.
Loading