release-bumper
Cut a release: bump versions, write changelogs, commit, tag.
Install
npx skills add https://github.com/PaulRBerg/agent-skills/tree/main/skills/release-bumper
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install paulrberg-agent-skills@llmmart
git clone https://github.com/PaulRBerg/agent-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole paulrberg/agent-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Release Bumper
If these instructions are already present in the conversation from a slash or dollar invocation, follow them directly; do not invoke this skill again through a skill tool.
Release one package or several packages with version bumps, changelog entries, commits, and tags.
A non-dry-run invocation authorizes the repository-local version edits, changelogs, commits, and annotated tags defined by this workflow. Do not add a generic confirmation gate after the agent derives the release plan. Ask only when an unresolved package selection or dependency-range policy changes the release set or approach; GitHub release creation remains a separate external write governed below.
Arguments
packages: optional package names or directories. Omit in a single-package repository.version: optional explicit semver. Valid only for one user-selected package.--beta: create or advance a-beta.Xprerelease.--dry-run: preview without modifying files, committing, or tagging.
Helper Interface
Resolve <skill-dir> from this SKILL.md. Keep helper stdout as JSON and diagnostics on stderr.
bun run "<skill-dir>/scripts/plan-release.ts" \
[--cwd <repo>] [--beta] [--dry-run] [--version <semver>] \
[--package <name-or-dir>]...
The read-only discovery output has schemaVersion: 2. It reports package identity, complete per-package changedFiles,
workspace edges and declared ranges, previous-tag facts, selected targets, and worktree state. changeHints are
filename-based, explicitly non-authoritative navigation hints. Never use them to decide release relevance or changelog
inclusion.
After the agent decides every stable patch/minor/major version, write discovery JSON to a temporary file and run:
uv run "<skill-dir>/scripts/finalize-release-plan.py" \
--discovery <discovery.json> \
[--version <package>=<semver>]...
The finalizer performs beta and explicit-version transitions, stable prerelease promotion, npm-range satisfaction,
simple dependency-range suggestions, and dependency ordering. It reports complex ranges, peer ranges, dependency cycles,
and stable versions not supplied by the agent as unresolved decisions. When an unsatisfied edge adds a dependent, choose
that package's release version and rerun with another --version assignment. The finalizer never chooses a regular
release magnitude or dependency policy.
For every stable changelog written, validate its deterministic structure:
uv run "<skill-dir>/scripts/validate-changelog.py" \
--file <CHANGELOG.md> --version <semver> --date <YYYY-MM-DD> [--tag <tag>]
This checks the expected release and date, heading/category order, allowed categories, list structure, and release-link tag. It does not judge importance, wording, or semantic category.
Workflow
Run discovery with the user arguments mapped directly. Exit
2means the target is not a releasable Git/package repository; exit64means invalid input. Stop on either.Require
workingTree.clean. Do not absorb unrelated work.Resolve unknown or ambiguous package selection. An explicit user version remains single-package only.
Inspect each target's complete
changedFilesand the net diff from its previous tag. Decide whether the surviving changes warrant a release. Runtime environments, refactors, documentation, tests, and tooling can all be relevant in context; filenames never decide this.For every relevant stable target without an explicit version, choose patch, minor, or major from the consumer-facing change. For beta releases, let the finalizer compute the mechanical transition.
Run the finalizer. Review unsatisfied workspace edges. Accept its suggestion only for a simple dependency range when that policy fits; choose peer and complex range policy explicitly. Add dependents and their agent-chosen release versions, then rerun until the package set and dependency order are resolved.
For a dry run, report the ordered package/version plan, range edits, changelog/tag/commit actions, and agent-decided skips. Stop before writes.
For a stable release, read
references/common-changelog.mdand write consumer-facing entries from the bounded net diff. The agent owns entry selection, wording, importance, and category. Beta releases do not update changelogs.Update manifests and accepted dependency ranges. Validate every stable changelog with the helper.
Format once using the repository's narrowest established command.
Commit and tag dependencies before dependents. Use one commit and one annotated tag per package:
- single-package commit:
docs: release <version>; - monorepo commit:
docs: release <package> <version>; - single-package tag: follow observed
v<version>or bare-semver facts; - monorepo tag: follow observed package tag facts, defaulting to
<package-dir>@<version>.
- single-package commit:
Do not push. After success, recommend an exact
git push origin <tag>...command containing only the tags created by this execution; do not use--tags.Before the final report, inspect
.github/workflows/for an active workflow that creates or publishes GitHub releases from pushed tags. A filename such asrelease.ymlis a hint, not proof. Use$cli-ghread-only to check whether the repository has an established history of maintained GitHub releases. If it does, offer to create a GitHub release for each new tag, pending the user's approval, according to these rules:- One to three tags and applicable release CI exists: do not offer manual release creation; the tag push should trigger CI.
- No applicable release CI exists: offer to create one release per new tag with
$cli-gh. - More than three tags will be pushed together: offer to create one release per tag with
$cli-gheven when release CI exists, because GitHub does not create tag push events above that threshold. See GitHub's push-event limits.
Never create a GitHub release without the user's approval. If release history cannot be verified, report it as unknown and do not offer the write.
Safety and Completion
Helper failures mean malformed input, violated invariants, or failed validation; an agent decision remaining unresolved is data in the JSON, not a helper failure. Discovery and dry-run are read-only. Do not write changelogs before the final stable package set is known, and do not infer a tag convention when discovery reports observed facts.
Dry-run completion requires a discovery-backed, agent-reviewed action preview with zero writes. Release completion requires validated manifests and stable changelogs, formatting, one commit and annotated tag per package in dependency order, and a report of created commits/tags, agent-decided skips, the exact tag-push command, and any applicable GitHub release proposal.
Use ### ⛔ Release stopped — working tree is not clean, ### ⚠️ Release decision required,
### 🔎 Release preview — no files, commits, or tags written, or ### 🏁 Release complete as applicable. Keep helper
JSON, versions, hashes, tags, commands, and changelog text exact and undecorated.
Files (agent-skills)
-
agents
-
openai.yaml 43 B
policy: allow_implicit_invocation: false
-
-
references
-
common-changelog.md 2.7 KB
# Common Changelog Write stable release notes for consumers. The structure validator owns mechanical conformance; this reference owns the agent's semantic and editorial decisions. Full specification: <https://common-changelog.org/>. ## Consumer Contract - Changelogs are for humans. Describe surviving user/developer impact, not the commit sequence. - Keep releases latest-first and include every new stable release. - Use `Changed`, `Added`, `Removed`, and `Fixed` according to meaning. Put the most important consumer effect first. - A first release or upgrade warning may use one emphasized notice. - Link the release version to the matching GitHub release/tag and each change to the best PR, falling back to a commit only when no PR exists. Run `scripts/validate-changelog.py` with the expected version, date, and tag after writing. Fix its structural errors; do not ask it to decide category, wording, importance, or whether a change belongs in the release. ## Write Entries Start each item with an imperative present-tense verb and make it understandable without its category heading: ```md ### Added - Support CentOS ([#28](https://github.com/owner/name/pull/28)) - Add `write()` streaming mode ([`53bd922`](https://github.com/owner/name/commit/53bd922)) ``` Mark breaking effects explicitly and place them before non-breaking items in the same category: ```md - **Breaking:** emit `close` after `end` - **Installer (breaking):** enable silent mode by default ``` Use subsystem prefixes only when they improve comprehension. Keep each change self-contained and brief; longer explanation belongs in the linked PR/commit unless the source lacks necessary context. ## Select Relevant Changes Inspect the complete bounded net diff. Exclude changes that do not matter to consumers, such as formatting-only churn, development-only dependency maintenance, or negated intermediate work. Do not exclude a change solely from its filename or location. Runtime environment changes, refactors, language-feature changes, and newly documented behavior may all be release-relevant. Rephrase inconsistent commit language into product terminology. Merge related commits and fixups into one surviving outcome. Prefer the PR that best explains the change; include at most the few references needed to reach that context. Author attribution is optional and useful only when the project's conventions make it meaningful. ## Prerelease Promotion For a stable release after prereleases, choose the consumer-appropriate narrative: merge the prerelease content into the stable entry, omit internal-only prerelease notes, or use a short notice referring to the prerelease. Write the stable release as the supported outcome, not as a transcript of beta iterations.
-
-
scripts
-
finalize-release-plan.py 13.9 KB
#!/usr/bin/env python3 """Finalize only the mechanical parts of a release discovery record.""" from __future__ import annotations import argparse import json import re import sys from dataclasses import dataclass from pathlib import Path from typing import Any SEMVER_RE = re.compile( r"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)" r"(?:-(?P<pre>[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$" ) SIMPLE_RANGE_RE = re.compile(r"^(?P<operator>\^|~|>=|<=|>|<|=)?(?P<version>\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$") class InputError(ValueError): pass @dataclass(frozen=True) class Version: major: int minor: int patch: int prerelease: tuple[str, ...] = () @classmethod def parse(cls, value: str) -> "Version": match = SEMVER_RE.fullmatch(value) if not match: raise InputError(f"invalid semver: {value}") prerelease = tuple((match.group("pre") or "").split(".")) if match.group("pre") else () return cls(int(match.group("major")), int(match.group("minor")), int(match.group("patch")), prerelease) def core(self) -> str: return f"{self.major}.{self.minor}.{self.patch}" def __str__(self) -> str: return self.core() + (f"-{'.'.join(self.prerelease)}" if self.prerelease else "") def compare(left: Version, right: Version) -> int: for a, b in zip((left.major, left.minor, left.patch), (right.major, right.minor, right.patch)): if a != b: return -1 if a < b else 1 if not left.prerelease and not right.prerelease: return 0 if not left.prerelease: return 1 if not right.prerelease: return -1 for a, b in zip(left.prerelease, right.prerelease): if a == b: continue a_num, b_num = a.isdigit(), b.isdigit() if a_num and b_num: return -1 if int(a) < int(b) else 1 if a_num != b_num: return -1 if a_num else 1 return -1 if a < b else 1 return (len(left.prerelease) > len(right.prerelease)) - (len(left.prerelease) < len(right.prerelease)) def beta_version(current: str, explicit: str | None) -> str: base = Version.parse(explicit) if explicit else Version.parse(current) if explicit: if base.prerelease: return str(base) return f"{base.core()}-beta.1" if len(base.prerelease) == 2 and base.prerelease[0] == "beta" and base.prerelease[1].isdigit(): return f"{base.core()}-beta.{int(base.prerelease[1]) + 1}" if base.prerelease: raise InputError(f"unsupported prerelease transition: {current}") return f"{base.major}.{base.minor}.{base.patch + 1}-beta.1" def stable_promotion(current: str) -> str | None: version = Version.parse(current) return version.core() if version.prerelease else None def range_details(value: str) -> dict[str, Any]: workspace_prefix = "" body = value if body.startswith("workspace:"): workspace_prefix = "workspace:" body = body[len(workspace_prefix) :] if body in {"*", "^", "~"}: return { "kind": "workspace-dynamic", "supported": True, "workspacePrefix": workspace_prefix, "operator": body, "version": None, } match = SIMPLE_RANGE_RE.fullmatch(body) if not match: return {"kind": "complex", "supported": False, "workspacePrefix": workspace_prefix} return { "kind": "simple", "supported": True, "workspacePrefix": workspace_prefix, "operator": match.group("operator") or "", "version": match.group("version"), } def satisfies(version_text: str, range_text: str) -> bool | None: details = range_details(range_text) if not details["supported"]: return None if details["kind"] == "workspace-dynamic": return True version = Version.parse(version_text) anchor = Version.parse(details["version"]) operator = details["operator"] relation = compare(version, anchor) if version.prerelease and not anchor.prerelease: return False if operator in {"", "="}: return relation == 0 if operator == ">=": return relation >= 0 if operator == ">": return relation > 0 if operator == "<=": return relation <= 0 if operator == "<": return relation < 0 if operator == "~": return relation >= 0 and version.major == anchor.major and version.minor == anchor.minor if operator == "^": if anchor.major > 0: return relation >= 0 and version.major == anchor.major if anchor.minor > 0: return relation >= 0 and version.major == 0 and version.minor == anchor.minor return relation >= 0 and version.major == 0 and version.minor == 0 and version.patch == anchor.patch raise AssertionError(operator) def suggested_range(range_text: str, version_text: str) -> str | None: details = range_details(range_text) if not details["supported"] or details["kind"] != "simple": return None return f"{details['workspacePrefix']}{details['operator']}{version_text}" def package_index(discovery: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], dict[str, set[str]]]: packages = discovery.get("packages") if not isinstance(packages, list): raise InputError("discovery packages must be an array") by_id: dict[str, dict[str, Any]] = {} aliases: dict[str, set[str]] = {} for package in packages: if not isinstance(package, dict) or not isinstance(package.get("id"), str): raise InputError("every package must have a string id") package_id = package["id"] by_id[package_id] = package for alias in {package_id, package.get("name"), package.get("dir")} - {None}: aliases.setdefault(str(alias), set()).add(package_id) return by_id, aliases def resolve_versions(discovery: dict[str, Any], explicit_args: list[str]) -> tuple[dict[str, dict[str, Any]], list[dict[str, str]]]: by_id, aliases = package_index(discovery) explicit: dict[str, str] = {} for assignment in explicit_args: if "=" not in assignment: raise InputError(f"--version must be PACKAGE=SEMVER: {assignment}") selector, version = assignment.split("=", 1) Version.parse(version) matches = aliases.get(selector, set()) if len(matches) != 1: qualifier = "unknown" if not matches else "ambiguous" raise InputError(f"{qualifier} package selector: {selector}") package_id = next(iter(matches)) if package_id in explicit: raise InputError(f"duplicate version for package: {package_id}") explicit[package_id] = version targets = discovery.get("targets") if not isinstance(targets, list): raise InputError("discovery targets must be an array") planner_explicit = discovery.get("explicitVersion") if planner_explicit is not None: Version.parse(planner_explicit) if len(targets) != 1: raise InputError("planner explicitVersion requires exactly one target") explicit.setdefault(targets[0]["id"], planner_explicit) versions: dict[str, dict[str, Any]] = {} unresolved: list[dict[str, str]] = [] for target in targets: package_id = target.get("id") if package_id not in by_id: raise InputError(f"target is absent from packages: {package_id}") current = target.get("version") if not isinstance(current, str): raise InputError(f"package has no version: {package_id}") Version.parse(current) planned: str | None source: str if discovery.get("beta"): planned = beta_version(current, explicit.get(package_id)) source = "explicit-beta" if package_id in explicit else "beta-transition" elif package_id in explicit: planned = explicit[package_id] source = "explicit" else: planned = stable_promotion(current) source = "prerelease-promotion" if planned else "agent-decision" versions[package_id] = {"current": current, "planned": planned, "source": source, "resolved": planned is not None} if planned is None: unresolved.append({"package": package_id, "decision": "choose patch, minor, or major version"}) for package_id, explicit_version in explicit.items(): if package_id in versions: continue current = by_id[package_id].get("version") if not isinstance(current, str): raise InputError(f"package has no version: {package_id}") Version.parse(current) planned = beta_version(current, explicit_version) if discovery.get("beta") else explicit_version versions[package_id] = { "current": current, "planned": planned, "source": "cascade-explicit-beta" if discovery.get("beta") else "cascade-explicit", "resolved": True, } return versions, unresolved def dependency_order(nodes: set[str], edges: list[dict[str, Any]]) -> tuple[list[str], list[list[str]]]: outgoing = {node: set() for node in nodes} indegree = {node: 0 for node in nodes} for edge in edges: dependent, dependency = edge.get("from"), edge.get("to") if dependent in nodes and dependency in nodes and dependent not in outgoing[dependency]: outgoing[dependency].add(dependent) indegree[dependent] += 1 ready = sorted(node for node, count in indegree.items() if count == 0) ordered: list[str] = [] while ready: node = ready.pop(0) ordered.append(node) for dependent in sorted(outgoing[node]): indegree[dependent] -= 1 if indegree[dependent] == 0: ready.append(dependent) ready.sort() cycles: list[list[str]] = [] stack: list[str] = [] on_stack: set[str] = set() indexes: dict[str, int] = {} lowlinks: dict[str, int] = {} next_index = 0 def visit(node: str) -> None: nonlocal next_index indexes[node] = lowlinks[node] = next_index next_index += 1 stack.append(node) on_stack.add(node) for adjacent in sorted(outgoing[node]): if adjacent not in indexes: visit(adjacent) lowlinks[node] = min(lowlinks[node], lowlinks[adjacent]) elif adjacent in on_stack: lowlinks[node] = min(lowlinks[node], indexes[adjacent]) if lowlinks[node] != indexes[node]: return component: list[str] = [] while True: adjacent = stack.pop() on_stack.remove(adjacent) component.append(adjacent) if adjacent == node: break component.sort() if len(component) > 1 or node in outgoing[node]: cycles.append(component) for node in sorted(nodes): if node not in indexes: visit(node) return ordered, sorted(cycles) def finalize(discovery: dict[str, Any], explicit_args: list[str]) -> dict[str, Any]: if discovery.get("schemaVersion") != 2: raise InputError("discovery schemaVersion must be 2") versions, unresolved = resolve_versions(discovery, explicit_args) edges = discovery.get("dependencyEdges") if not isinstance(edges, list): raise InputError("discovery dependencyEdges must be an array") evaluated: list[dict[str, Any]] = [] release_nodes = set(versions) for edge in edges: dependency_version = versions.get(edge.get("to"), {}).get("planned") if dependency_version is None: continue declared_range = edge.get("range") if not isinstance(declared_range, str): raise InputError("dependency edge range must be a string") satisfied = satisfies(dependency_version, declared_range) details = range_details(declared_range) item = { **edge, "plannedDependencyVersion": dependency_version, "rangeKind": details["kind"], "satisfied": satisfied, "suggestedRange": None, "decision": None, } if satisfied is False: release_nodes.add(edge["from"]) if edge.get("type") == "peerDependencies": item["decision"] = "choose peer dependency range policy" elif details["kind"] == "simple": item["suggestedRange"] = suggested_range(declared_range, dependency_version) else: item["decision"] = "choose complex dependency range policy" elif satisfied is None: item["decision"] = "evaluate unsupported dependency range" evaluated.append(item) order, cycles = dependency_order(release_nodes, edges) if cycles: unresolved.append({"package": ",".join(cycles[0]), "decision": "resolve dependency cycle ordering"}) return { "schemaVersion": 1, "discoverySchemaVersion": 2, "versions": versions, "workspaceEdges": evaluated, "unsatisfiedWorkspaceEdges": [edge for edge in evaluated if edge["satisfied"] is not True], "dependencyOrder": order, "dependencyCycles": cycles, "unresolvedDecisions": unresolved, "tagFacts": discovery.get("previousTags", {}), } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--discovery", required=True, type=Path) parser.add_argument("--version", action="append", default=[], metavar="PACKAGE=SEMVER") args = parser.parse_args() try: discovery = json.loads(args.discovery.read_text(encoding="utf-8")) result = finalize(discovery, args.version) except (OSError, json.JSONDecodeError, InputError) as exc: print(f"ERROR: {exc}", file=sys.stderr) return 64 print(json.dumps(result, indent=2, sort_keys=True)) return 0 if __name__ == "__main__": raise SystemExit(main()) -
plan-release.ts 20.6 KB
#!/usr/bin/env bun import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import process from "node:process"; type CliOptions = { beta: boolean; cwd: string; dryRun: boolean; help: boolean; packages: string[]; version: string | null; }; type PackageManifest = { dependencies?: Record<string, string>; files?: string[]; name?: string; peerDependencies?: Record<string, string>; version?: string; workspaces?: string[] | { packages?: string[] }; }; type PackageRecord = { absDir: string; dependencies: Record<string, string>; dir: string; files: string[] | null; id: string; name: string | null; peerDependencies: Record<string, string>; version: string | null; }; type ResolveTargetOptions = { cwd: string; errors: string[]; isMonorepo: boolean; packages: PackageRecord[]; repoRoot: string; selectors: string[]; }; type Semver = { major: number; minor: number; patch: number; pre: string[] }; type ParsedPackageTag = { hasLeadingV: boolean; name: string; version: string }; const ignoredDirs = new Set([ ".git", ".next", ".venv", "build", "coverage", "dist", "node_modules", "out", "target", "vendor", ]); const options = parseArgs(process.argv.slice(2)); if (options.help) { printUsage(); process.exit(0); } const cwd = path.resolve(options.cwd); const argErrors: string[] = []; if (options.version && !isSemver(options.version)) { argErrors.push(`invalid --version: ${options.version}`); } let repoRoot; try { repoRoot = git(["rev-parse", "--show-toplevel"], cwd).trim(); } catch { console.error(`ERROR: not inside a git repository: ${cwd}`); process.exit(2); } const rootPackagePath = path.join(repoRoot, "package.json"); if (!fs.existsSync(rootPackagePath)) { console.error(`ERROR: no package.json found at repo root: ${repoRoot}`); process.exit(2); } const rootPackage = readJson(rootPackagePath, argErrors); const hasPnpmWorkspace = fs.existsSync(path.join(repoRoot, "pnpm-workspace.yaml")); const workspacePatterns = workspaceGlobs(repoRoot, rootPackage); const isMonorepo = workspacePatterns.length > 0; const packageRecords: PackageRecord[] = isMonorepo ? discoverWorkspacePackages(repoRoot, workspacePatterns, argErrors, { preferPnpm: hasPnpmWorkspace }) : [readPackage(repoRoot, repoRoot, argErrors)].filter(isPresent); const releaseTags = gitTags(repoRoot); const selectedTargets = resolveTargets({ cwd, isMonorepo, packages: packageRecords, repoRoot, selectors: options.packages, errors: argErrors, }); if (options.version && selectedTargets.length > 1) { argErrors.push("explicit --version is only valid for a single target package"); } const output = { schemaVersion: 2, cwd, repoRoot, mode: isMonorepo ? "monorepo" : "single-package", beta: options.beta, dryRun: options.dryRun, explicitVersion: options.version ?? null, needsSelection: isMonorepo && options.packages.length === 0 && selectedTargets.length === 0, errors: argErrors, workingTree: readWorkingTree(repoRoot), workspacePatterns, packages: packageRecords.map(serializePackage), targets: selectedTargets.map(serializePackage), dependencyEdges: dependencyEdges(packageRecords), previousTags: Object.fromEntries(packageRecords.map((pkg) => [pkg.id, previousTag(pkg, isMonorepo, releaseTags)])), changedFiles: {} as Record<string, string[]>, changeHints: { authoritative: false, byPackage: {} as Record<string, Array<{ hint: string; path: string }>> }, }; for (const pkg of packageRecords) { const tag = output.previousTags[pkg.id]?.tag ?? null; const changed = changedFiles(repoRoot, pkg, tag); output.changedFiles[pkg.id] = changed; output.changeHints.byPackage[pkg.id] = changed.map((file) => ({ path: file, hint: fileHint(pkg, file) })); } console.log(`${JSON.stringify(output, null, 2)}\n`); process.exit(argErrors.length > 0 ? 64 : 0); function parseArgs(args: string[]): CliOptions { const parsed: CliOptions = { beta: false, cwd: process.cwd(), dryRun: false, help: false, packages: [], version: null, }; for (let i = 0; i < args.length; i += 1) { const arg = args[i]; if (!arg) continue; if (arg === "--help" || arg === "-h") { parsed.help = true; } else if (arg === "--beta") { parsed.beta = true; } else if (arg === "--dry-run") { parsed.dryRun = true; } else if (arg === "--cwd") { parsed.cwd = requireValue(args, (i += 1), "--cwd"); } else if (arg.startsWith("--cwd=")) { parsed.cwd = arg.slice("--cwd=".length); } else if (arg === "--version") { parsed.version = requireValue(args, (i += 1), "--version"); } else if (arg.startsWith("--version=")) { parsed.version = arg.slice("--version=".length); } else if (arg === "--package") { parsed.packages.push(requireValue(args, (i += 1), "--package")); } else if (arg.startsWith("--package=")) { parsed.packages.push(arg.slice("--package=".length)); } else if (arg.startsWith("-")) { throwUsage(`unknown option: ${arg}`); } else if (!parsed.version && isSemver(arg)) { parsed.version = arg; } else { parsed.packages.push(arg); } } return parsed; } function requireValue(args: string[], index: number, flag: string): string { const value = args[index]; if (!value || value.startsWith("--")) throwUsage(`${flag} requires a value`); return value; } function throwUsage(message: string): never { console.error(`ERROR: ${message}\n`); printUsage(); process.exit(64); } function printUsage(): void { console.error( `Usage: plan-release.ts [--cwd <repo>] [--beta] [--dry-run] [--version <semver>] [--package <name-or-dir>]...`, ); } function git(args: string[], cwdArg: string): string { return execFileSync("git", args, { cwd: cwdArg, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); } function readJson(filePath: string, errors: string[]): PackageManifest { try { return JSON.parse(fs.readFileSync(filePath, "utf8")) as PackageManifest; } catch (error) { errors.push(`${path.relative(process.cwd(), filePath)}: ${errorMessage(error)}`); return {}; } } function workspaceGlobs(root: string, rootPackageJson: PackageManifest): string[] { const globs: string[] = []; const workspaces = rootPackageJson.workspaces; if (Array.isArray(workspaces)) { globs.push(...workspaces); } else if (workspaces && Array.isArray(workspaces.packages)) { globs.push(...workspaces.packages); } const pnpmWorkspace = path.join(root, "pnpm-workspace.yaml"); if (fs.existsSync(pnpmWorkspace)) { globs.push(...readPnpmWorkspaceGlobs(pnpmWorkspace)); } return unique(globs.filter((glob) => typeof glob === "string" && glob)); } function readPnpmWorkspaceGlobs(filePath: string): string[] { const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/); const globs: string[] = []; let inPackages = false; for (const line of lines) { if (/^packages:\s*$/.test(line)) { inPackages = true; continue; } if (inPackages && /^\S/.test(line)) break; const match = inPackages ? line.match(/^\s*-\s*['"]?([^'"]+)['"]?\s*$/) : null; if (match?.[1]) globs.push(match[1]); } return globs; } function discoverWorkspacePackages( root: string, globs: string[], errors: string[], { preferPnpm = false }: { preferPnpm?: boolean } = {}, ): PackageRecord[] { const pnpmPackages = preferPnpm ? discoverPnpmWorkspacePackages(root, errors) : null; if (pnpmPackages?.length) return pnpmPackages; const packageDirs = findPackageDirs(root) .filter((dir) => dir !== root) .filter((dir) => { if (globs.length === 0) return true; const rel = slash(path.relative(root, dir)); return matchesWorkspaceGlobs(rel, globs); }) .sort((a, b) => slash(path.relative(root, a)).localeCompare(slash(path.relative(root, b)))); return packageDirs.map((dir) => readPackage(root, dir, errors)).filter(isPresent); } function discoverPnpmWorkspacePackages(root: string, errors: string[]): PackageRecord[] | null { try { const projects = JSON.parse( execFileSync("pnpm", ["--dir", root, "list", "-r", "--depth", "-1", "--json"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }), ) as Array<{ path?: string }>; if (!Array.isArray(projects)) return null; return projects .map((project) => workspaceProjectDir(root, project.path)) .filter((dir): dir is string => Boolean(dir && dir !== root)) .map((dir) => readPackage(root, dir, errors)) .filter(isPresent) .sort((a, b) => a.dir.localeCompare(b.dir)); } catch { return null; } } function workspaceProjectDir(root: string, projectPath?: string): string | null { if (!projectPath) return null; const rootReal = safeRealpath(root); const projectReal = safeRealpath(projectPath); const rel = slash(path.relative(rootReal, projectReal)); if (rel === "" || rel.startsWith("../") || rel === "..") return null; return path.join(root, rel); } function safeRealpath(value: string): string { try { return fs.realpathSync.native(value); } catch { return path.resolve(value); } } function findPackageDirs(root: string): string[] { const results: string[] = []; walk(root); return results; function walk(dir: string): void { const base = path.basename(dir); if (ignoredDirs.has(base)) return; const packagePath = path.join(dir, "package.json"); if (fs.existsSync(packagePath)) results.push(dir); for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; if (ignoredDirs.has(entry.name)) continue; walk(path.join(dir, entry.name)); } } } function readPackage(root: string, dir: string, errors: string[]): PackageRecord | null { const packagePath = path.join(dir, "package.json"); if (!fs.existsSync(packagePath)) return null; const manifest = readJson(packagePath, errors); const rel = slash(path.relative(root, dir)) || "."; const id = rel === "." ? manifest.name || "." : rel; return { id, dir: rel, absDir: dir, name: manifest.name ?? null, version: manifest.version ?? null, files: Array.isArray(manifest.files) ? manifest.files : null, dependencies: manifest.dependencies ?? {}, peerDependencies: manifest.peerDependencies ?? {}, }; } function resolveTargets({ cwd: cwdArg, errors, isMonorepo, packages, repoRoot: root, selectors, }: ResolveTargetOptions): PackageRecord[] { if (!isMonorepo) return packages; if (selectors.length > 0) { const selected: PackageRecord[] = []; for (const selector of selectors) { const matches = packages.filter((pkg) => packageMatches(pkg, selector)); if (matches.length === 0) { errors.push(`unknown package selector: ${selector}`); } else if (matches.length > 1) { errors.push(`ambiguous package selector: ${selector}`); } else { const match = matches[0]; if (match && !selected.some((pkg) => pkg.id === match.id)) selected.push(match); } } return selected; } const matches = packages.filter((pkg) => { const rel = pkg.dir === "." ? "" : pkg.dir; const abs = path.join(root, rel); return cwdArg === abs || cwdArg.startsWith(`${abs}${path.sep}`); }); return matches.length === 1 ? matches : []; } function packageMatches(pkg: PackageRecord, selector: string): boolean { const normalized = selector.replace(/\/$/, ""); const base = path.basename(pkg.dir); const unscoped = pkg.name?.startsWith("@") ? pkg.name.split("/").at(-1) : pkg.name; return normalized === pkg.dir || normalized === base || normalized === pkg.name || normalized === unscoped; } function serializePackage(pkg: PackageRecord) { return { id: pkg.id, dir: pkg.dir, name: pkg.name, version: pkg.version, files: pkg.files, dependencyNames: Object.keys(pkg.dependencies).sort(), peerDependencyNames: Object.keys(pkg.peerDependencies).sort(), }; } function dependencyEdges(packages: PackageRecord[]) { const byName = new Map( packages.filter((pkg): pkg is PackageRecord & { name: string } => Boolean(pkg.name)).map((pkg) => [pkg.name, pkg]), ); const edges: Array<{ from: string; name: string; range: string; to: string; type: string }> = []; for (const from of packages) { const dependencySets: Array<[string, Record<string, string>]> = [ ["dependencies", from.dependencies], ["peerDependencies", from.peerDependencies], ]; for (const [type, deps] of dependencySets) { for (const [name, range] of Object.entries(deps)) { const to = byName.get(name); if (to) edges.push({ from: from.id, to: to.id, type, name, range }); } } } return edges.sort((a, b) => `${a.from}:${a.to}:${a.type}`.localeCompare(`${b.from}:${b.to}:${b.type}`)); } function previousTag(pkg: PackageRecord, isMonorepoArg: boolean, allTags: string[]) { const candidates = tagPatterns(pkg, isMonorepoArg); const parsed = candidateTags(pkg, isMonorepoArg, allTags) .map((tag) => ({ tag, version: versionFromTag(tag, isMonorepoArg) })) .filter((entry): entry is { tag: string; version: string } => Boolean(entry.version)) .sort((a, b) => compareSemverDesc(a.version, b.version)); return { tag: parsed[0]?.tag ?? null, version: parsed[0]?.version ?? null, patterns: candidates, }; } function tagPatterns(pkg: PackageRecord, isMonorepoArg: boolean): string[] { if (!isMonorepoArg) return ["v[0-9]*.[0-9]*.[0-9]*", "[0-9]*.[0-9]*.[0-9]*"]; return packageTagNames(pkg).flatMap((name) => [`${name}@[0-9]*.[0-9]*.[0-9]*`, `${name}@v[0-9]*.[0-9]*.[0-9]*`]); } function gitTags(root: string): string[] { try { return git(["tag", "--list", "--sort=-creatordate"], root).split(/\r?\n/).filter(Boolean); } catch { return []; } } function candidateTags(pkg: PackageRecord, isMonorepoArg: boolean, allTags: string[]): string[] { if (!isMonorepoArg) { return unique([ ...allTags.filter((tag) => tag.startsWith("v") && stripLeadingV(tag)), ...allTags.filter((tag) => !tag.startsWith("v") && stripLeadingV(tag)), ]); } const matches: string[] = []; for (const name of packageTagNames(pkg)) { for (const hasLeadingV of [false, true]) { matches.push( ...allTags.filter((tag) => { const parsed = parsePackageTag(tag); return parsed?.name === name && parsed.hasLeadingV === hasLeadingV; }), ); } } return unique(matches); } function packageTagNames(pkg: PackageRecord): string[] { const names = new Set<string>([pkg.dir, path.basename(pkg.dir)]); if (pkg.name) { names.add(pkg.name); if (pkg.name.startsWith("@")) { const unscoped = pkg.name.split("/").at(-1); if (unscoped) names.add(unscoped); } } return [...names].filter(Boolean); } function parsePackageTag(tag: string): ParsedPackageTag | null { const at = tag.lastIndexOf("@"); if (at <= 0) return null; const name = tag.slice(0, at); const suffix = tag.slice(at + 1); const hasLeadingV = suffix.startsWith("v"); const version = stripLeadingV(suffix); return version ? { hasLeadingV, name, version } : null; } function versionFromTag(tag: string, isMonorepoArg: boolean): string | null { if (!isMonorepoArg) return stripLeadingV(tag); return parsePackageTag(tag)?.version ?? null; } function changedFiles(root: string, pkg: PackageRecord, tag: string | null): string[] { const args = tag ? ["diff", "--name-only", `${tag}..HEAD`, "--"] : ["ls-files", "--"]; if (pkg.dir !== ".") args.push(pkg.dir); return git(args, root).split(/\r?\n/).filter(Boolean).sort(); } function fileHint(pkg: PackageRecord, repoRel: string): string { const rel = slash(repoRel); const packageRel = pkg.dir === "." ? repoRel : slash(path.relative(pkg.dir, repoRel)); const local = slash(packageRel); const base = path.basename(local); if (/^(\.github|\.gitlab|\.circleci|\.husky)\//.test(rel)) return "ci"; if ( /(^|\/)(__tests__|tests?|fixtures?|mocks?)\//i.test(local) || /\.(test|spec|bench|fixture)\.[cm]?[jt]sx?$/i.test(base) ) { return "test"; } if (/^(eslint|prettier|biome|vitest|jest|commitlint|lint-staged|lefthook|husky)\.config\./.test(base)) return "tooling"; if (/^(\.eslintrc|\.prettierrc|\.lintstagedrc|\.npmrc|\.node-version|\.nvmrc)$/.test(base)) return "tooling"; if (/^(justfile|Makefile|Dockerfile)$/.test(base)) return "tooling"; if (/^(pnpm-lock\.yaml|bun\.lockb?|package-lock\.json|yarn\.lock)$/.test(base)) return "tooling"; if (pkg.files) { const filesMatch = pkg.files.some((entry) => { const normalized = normalizeGlob(entry).replace(/\/$/, ""); if (!hasGlob(normalized)) return local === normalized || local.startsWith(`${normalized}/`); return matchGlob(local, normalized); }); if (!filesMatch && local !== "package.json") return "outside-package-files"; } return "runtime-or-docs"; } function readWorkingTree(root: string): { clean: boolean; status: string[] } { const status = git(["status", "--porcelain=v1"], root).split(/\r?\n/).filter(Boolean); return { clean: status.length === 0, status }; } function matchesWorkspaceGlobs(rel: string, globs: string[]): boolean { const positive: string[] = []; const negative: string[] = []; for (const glob of globs) { const negated = glob.startsWith("!"); const normalized = normalizeGlob(negated ? glob.slice(1) : glob).replace(/\/$/, ""); if (!normalized) continue; (negated ? negative : positive).push(normalized); } const included = positive.length === 0 || positive.some((glob) => matchWorkspaceGlob(rel, glob)); return included && !negative.some((glob) => matchWorkspaceGlob(rel, glob)); } function matchWorkspaceGlob(rel: string, glob: string): boolean { if (!hasGlob(glob)) return rel === glob || rel.startsWith(`${glob}/`); return matchGlob(rel, glob); } function normalizeGlob(glob: string): string { return slash(glob) .replace(/^\.\//, "") .replace(/\/package\.json$/, ""); } function matchGlob(value: string, glob: string): boolean { return globToRegex(glob).test(value); } function globToRegex(glob: string): RegExp { let source = "^"; for (let i = 0; i < glob.length; i += 1) { const char = glob[i]; const next = glob[i + 1]; if (char === "*" && next === "*") { if (glob[i + 2] === "/") { source += "(?:.*/)?"; i += 2; } else { source += ".*"; i += 1; } } else if (char === "*") { source += "[^/]*"; } else if (char === "?") { source += "[^/]"; } else { if (char) source += escapeRegex(char); } } source += "$"; return new RegExp(source); } function hasGlob(value: string): boolean { return /[*?[\]{}]/.test(value); } function escapeRegex(value: string): string { return value.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"); } function slash(value: string): string { return value.split(path.sep).join("/"); } function unique<T>(values: T[]): T[] { return [...new Set(values)]; } function isSemver(value: string): boolean { return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?(?:\+[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?$/.test(value); } function stripLeadingV(value: string): string | null { const version = value.startsWith("v") ? value.slice(1) : value; return isSemver(version) ? version : null; } function compareSemverDesc(a: string, b: string): number { return -compareSemver(a, b); } function compareSemver(a: string, b: string): number { const left = parseSemver(a); const right = parseSemver(b); for (const key of ["major", "minor", "patch"] as const) { if (left[key] !== right[key]) return left[key] - right[key]; } if (left.pre.length === 0 && right.pre.length === 0) return 0; if (left.pre.length === 0) return 1; if (right.pre.length === 0) return -1; const length = Math.max(left.pre.length, right.pre.length); for (let i = 0; i < length; i += 1) { const l = left.pre[i]; const r = right.pre[i]; if (l === undefined) return -1; if (r === undefined) return 1; if (l === r) continue; const lNum = /^\d+$/.test(l); const rNum = /^\d+$/.test(r); if (lNum && rNum) return Number(l) - Number(r); if (lNum) return -1; if (rNum) return 1; return l.localeCompare(r); } return 0; } function parseSemver(value: string): Semver { const withoutBuild = value.split("+")[0] ?? value; const [core = "0.0.0", pre = ""] = withoutBuild.split("-"); const [major = 0, minor = 0, patch = 0] = core.split(".").map(Number); return { major, minor, patch, pre: pre ? pre.split(".") : [] }; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function isPresent<T>(value: T | null | undefined): value is T { return value !== null && value !== undefined; } -
validate-changelog.py 4.9 KB
#!/usr/bin/env python3 """Validate deterministic Common Changelog structure for one release.""" from __future__ import annotations import argparse import datetime as dt import json import re import sys from pathlib import Path CATEGORIES = ("Changed", "Added", "Removed", "Fixed") RELEASE_RE = re.compile(r"^## (?P<linked>\[)?(?P<version>\d+\.\d+\.\d+)(?(linked)\]) - (?P<date>\d{4}-\d{2}-\d{2})$") REFERENCE_RE = re.compile(r"^\[(?P<version>\d+\.\d+\.\d+)\]:\s+(?P<url>\S+)\s*$") def validate(text: str, version: str, date: str, tag: str | None) -> list[str]: errors: list[str] = [] try: dt.date.fromisoformat(date) except ValueError: return [f"invalid expected date: {date}"] if not re.fullmatch(r"\d+\.\d+\.\d+", version): return [f"invalid stable version: {version}"] lines = text.splitlines() if not lines or lines[0] != "# Changelog": errors.append("file must start with '# Changelog'") releases = [(index, match) for index, line in enumerate(lines) if (match := RELEASE_RE.fullmatch(line))] expected = [(index, match) for index, match in releases if match.group("version") == version] if len(expected) != 1: errors.append(f"expected exactly one release heading for {version}") return errors start, heading = expected[0] if heading.group("date") != date: errors.append(f"release {version} date is {heading.group('date')}, expected {date}") if releases and releases[0][0] != start: errors.append(f"release {version} is not the first release entry") end = next((index for index in range(start + 1, len(lines)) if lines[index].startswith("## ")), len(lines)) body = lines[start + 1 : end] headings: list[tuple[int, str]] = [] for offset, line in enumerate(body): if line.startswith("### "): category = line[4:] if category not in CATEGORIES: errors.append(f"unsupported category: {category}") else: headings.append((offset, category)) elif line.startswith("####"): errors.append("release entries may not contain fourth-level headings") category_names = [category for _, category in headings] if not category_names and not any(line.startswith("_") and line.endswith("_") for line in body): errors.append("release must contain a notice or change category") if len(category_names) != len(set(category_names)): errors.append("release categories may not repeat") if category_names != sorted(category_names, key=CATEGORIES.index): errors.append("release categories are out of order") for position, (offset, category) in enumerate(headings): group_end = headings[position + 1][0] if position + 1 < len(headings) else len(body) content = [ line for line in body[offset + 1 : group_end] if line.strip() and not REFERENCE_RE.fullmatch(line) ] bullets = [line for line in content if line.startswith("- ")] if not bullets: errors.append(f"category {category} must contain an unnumbered list") if any(re.match(r"^\d+[.)]\s", line) for line in content): errors.append(f"category {category} contains a numbered list") if any(line.startswith((" ", "\t")) for line in content): errors.append(f"category {category} contains a multiline or nested list item") if any(not line.startswith("- ") for line in content): errors.append(f"category {category} contains non-list content") references = { match.group("version"): match.group("url") for line in lines if (match := REFERENCE_RE.fullmatch(line)) } if heading.group("linked"): url = references.get(version) if not url: errors.append(f"linked release {version} has no reference definition") else: actual_tag = url.rstrip("/").rsplit("/", 1)[-1] allowed_tags = {tag} if tag else {version, f"v{version}"} if actual_tag not in allowed_tags: errors.append(f"release link tag is {actual_tag}, expected {' or '.join(sorted(allowed_tags))}") else: errors.append(f"release {version} heading must be linked") return errors def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--file", required=True, type=Path) parser.add_argument("--version", required=True) parser.add_argument("--date", required=True) parser.add_argument("--tag") args = parser.parse_args() try: text = args.file.read_text(encoding="utf-8") except OSError as exc: print(f"ERROR: {exc}", file=sys.stderr) return 64 errors = validate(text, args.version, args.date, args.tag) print(json.dumps({"schemaVersion": 1, "valid": not errors, "errors": errors}, indent=2)) return 0 if not errors else 1 if __name__ == "__main__": raise SystemExit(main())
-
-
SKILL.md 7.4 KB
--- argument-hint: "[packages...] [version] [--beta] [--dry-run]" compatibility: Requires Bun, Git, and uv. disable-model-invocation: true effort: high model: sonnet name: release-bumper skill-dependencies: - cli-gh description: "Cut a release: bump versions, write changelogs, commit, tag." --- # Release Bumper If these instructions are already present in the conversation from a slash or dollar invocation, follow them directly; do not invoke this skill again through a skill tool. Release one package or several packages with version bumps, changelog entries, commits, and tags. A non-dry-run invocation authorizes the repository-local version edits, changelogs, commits, and annotated tags defined by this workflow. Do not add a generic confirmation gate after the agent derives the release plan. Ask only when an unresolved package selection or dependency-range policy changes the release set or approach; GitHub release creation remains a separate external write governed below. ## Arguments - `packages`: optional package names or directories. Omit in a single-package repository. - `version`: optional explicit semver. Valid only for one user-selected package. - `--beta`: create or advance a `-beta.X` prerelease. - `--dry-run`: preview without modifying files, committing, or tagging. ## Helper Interface Resolve `<skill-dir>` from this `SKILL.md`. Keep helper stdout as JSON and diagnostics on stderr. ```sh bun run "<skill-dir>/scripts/plan-release.ts" \ [--cwd <repo>] [--beta] [--dry-run] [--version <semver>] \ [--package <name-or-dir>]... ``` The read-only discovery output has `schemaVersion: 2`. It reports package identity, complete per-package `changedFiles`, workspace edges and declared ranges, previous-tag facts, selected targets, and worktree state. `changeHints` are filename-based, explicitly non-authoritative navigation hints. Never use them to decide release relevance or changelog inclusion. After the agent decides every stable patch/minor/major version, write discovery JSON to a temporary file and run: ```sh uv run "<skill-dir>/scripts/finalize-release-plan.py" \ --discovery <discovery.json> \ [--version <package>=<semver>]... ``` The finalizer performs beta and explicit-version transitions, stable prerelease promotion, npm-range satisfaction, simple dependency-range suggestions, and dependency ordering. It reports complex ranges, peer ranges, dependency cycles, and stable versions not supplied by the agent as unresolved decisions. When an unsatisfied edge adds a dependent, choose that package's release version and rerun with another `--version` assignment. The finalizer never chooses a regular release magnitude or dependency policy. For every stable changelog written, validate its deterministic structure: ```sh uv run "<skill-dir>/scripts/validate-changelog.py" \ --file <CHANGELOG.md> --version <semver> --date <YYYY-MM-DD> [--tag <tag>] ``` This checks the expected release and date, heading/category order, allowed categories, list structure, and release-link tag. It does not judge importance, wording, or semantic category. ## Workflow 1. Run discovery with the user arguments mapped directly. Exit `2` means the target is not a releasable Git/package repository; exit `64` means invalid input. Stop on either. 2. Require `workingTree.clean`. Do not absorb unrelated work. 3. Resolve unknown or ambiguous package selection. An explicit user version remains single-package only. 4. Inspect each target's complete `changedFiles` and the net diff from its previous tag. Decide whether the surviving changes warrant a release. Runtime environments, refactors, documentation, tests, and tooling can all be relevant in context; filenames never decide this. 5. For every relevant stable target without an explicit version, choose patch, minor, or major from the consumer-facing change. For beta releases, let the finalizer compute the mechanical transition. 6. Run the finalizer. Review unsatisfied workspace edges. Accept its suggestion only for a simple dependency range when that policy fits; choose peer and complex range policy explicitly. Add dependents and their agent-chosen release versions, then rerun until the package set and dependency order are resolved. 7. For a dry run, report the ordered package/version plan, range edits, changelog/tag/commit actions, and agent-decided skips. Stop before writes. 8. For a stable release, read `references/common-changelog.md` and write consumer-facing entries from the bounded net diff. The agent owns entry selection, wording, importance, and category. Beta releases do not update changelogs. 9. Update manifests and accepted dependency ranges. Validate every stable changelog with the helper. 10. Format once using the repository's narrowest established command. 11. Commit and tag dependencies before dependents. Use one commit and one annotated tag per package: - single-package commit: `docs: release <version>`; - monorepo commit: `docs: release <package> <version>`; - single-package tag: follow observed `v<version>` or bare-semver facts; - monorepo tag: follow observed package tag facts, defaulting to `<package-dir>@<version>`. 12. Do not push. After success, recommend an exact `git push origin <tag>...` command containing only the tags created by this execution; do not use `--tags`. 13. Before the final report, inspect `.github/workflows/` for an active workflow that creates or publishes GitHub releases from pushed tags. A filename such as `release.yml` is a hint, not proof. Use `$cli-gh` read-only to check whether the repository has an established history of maintained GitHub releases. If it does, offer to create a GitHub release for each new tag, pending the user's approval, according to these rules: - One to three tags and applicable release CI exists: do not offer manual release creation; the tag push should trigger CI. - No applicable release CI exists: offer to create one release per new tag with `$cli-gh`. - More than three tags will be pushed together: offer to create one release per tag with `$cli-gh` even when release CI exists, because GitHub does not create tag push events above that threshold. See [GitHub's push-event limits](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#push). Never create a GitHub release without the user's approval. If release history cannot be verified, report it as unknown and do not offer the write. ## Safety and Completion Helper failures mean malformed input, violated invariants, or failed validation; an agent decision remaining unresolved is data in the JSON, not a helper failure. Discovery and dry-run are read-only. Do not write changelogs before the final stable package set is known, and do not infer a tag convention when discovery reports observed facts. Dry-run completion requires a discovery-backed, agent-reviewed action preview with zero writes. Release completion requires validated manifests and stable changelogs, formatting, one commit and annotated tag per package in dependency order, and a report of created commits/tags, agent-decided skips, the exact tag-push command, and any applicable GitHub release proposal. Use `### ⛔ Release stopped — working tree is not clean`, `### ⚠️ Release decision required`, `### 🔎 Release preview — no files, commits, or tags written`, or `### 🏁 Release complete` as applicable. Keep helper JSON, versions, hashes, tags, commands, and changelog text exact and undecorated.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.