Claude Skill

release-bumper

Cut a release: bump versions, write changelogs, commit, tag.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download paulrberg-agent-skills-skills_release-bumper-913232a.zip · 16 KB
Part of paulrberg/agent-skills — 42 skills

Install

skills CLI npx skills add https://github.com/PaulRBerg/agent-skills/tree/main/skills/release-bumper
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install paulrberg-agent-skills@llmmart
Git 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.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.

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

  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.

    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.

No comments yet.

Reviews (0)

No reviews yet.

Related