From 0d51ede45358b3dc53b7b70ad2037e3ebdeffe01 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah Date: Tue, 9 Jun 2026 16:50:39 -0700 Subject: [PATCH] Add read-only basectl release assistant --- CHANGELOG.md | 2 + README.md | 20 +- base_manifest.yaml | 13 + cli/bash/commands/basectl/basectl.sh | 8 + .../commands/basectl/subcommands/release.sh | 42 ++ cli/bash/commands/basectl/tests/help.bats | 2 + cli/bash/commands/basectl/tests/release.bats | 43 ++ cli/python/base_release/__init__.py | 1 + cli/python/base_release/__main__.py | 5 + cli/python/base_release/engine.py | 404 ++++++++++++++++++ cli/python/base_release/tests/test_engine.py | 272 ++++++++++++ cli/python/base_setup/manifest.py | 180 ++++++++ cli/python/base_setup/tests/test_manifest.py | 144 +++++++ docs/architecture.md | 1 + docs/execution-model.md | 1 + docs/release-process.md | 24 ++ ...2026-06-09-release-assistant-foundation.md | 177 ++++++++ lib/shell/completions/basectl_completion.sh | 9 +- lib/shell/completions/basectl_completion.zsh | 7 + 19 files changed, 1353 insertions(+), 2 deletions(-) create mode 100644 cli/bash/commands/basectl/subcommands/release.sh create mode 100644 cli/bash/commands/basectl/tests/release.bats create mode 100644 cli/python/base_release/__init__.py create mode 100644 cli/python/base_release/__main__.py create mode 100644 cli/python/base_release/engine.py create mode 100644 cli/python/base_release/tests/test_engine.py create mode 100644 docs/superpowers/plans/2026-06-09-release-assistant-foundation.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e1acad4..c18db7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and Base versions are tracked in the repo-root `VERSION` file. - Added read-only workspace manifest support for `basectl workspace status`, `check`, and `doctor` with `--manifest `. +- Added read-only `basectl release check|plan|notes` commands backed by + manifest-owned release metadata. - Added opt-in project Git `origin` reachability diagnostics with `basectl check|doctor --remote-network`. - Added an explicit `ai` prerequisite profile for Codex CLI and Claude Code diff --git a/README.md b/README.md index 33d12b5..b80efa9 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,9 @@ Current implemented commands include: - `basectl repo check [path]` - `basectl repo configure [path]` - `basectl repo agent-guidance [path]` +- `basectl release check --version ` +- `basectl release plan --version ` +- `basectl release notes --version ` - `basectl activate ` - `basectl test [project]` - `basectl build [target...]` @@ -560,6 +563,21 @@ basectl config doctor Base owns the meaning of `~/.base.d/config.yaml`, but users own how that file is edited, backed up, or synced. See [docs/local-config.md](docs/local-config.md). +Inspect release readiness for a Base-managed repository with: + +```bash +basectl release check --version 0.4.0 +basectl release plan --version 0.4.0 +basectl release notes --version 0.4.0 +``` + +`basectl release` is read-only in this first slice. It validates the manifest +release contract, version file, changelog section, Git worktree state, GitHub +CLI authentication, and local and remote tag availability, then prints the +GitHub release target and any required Homebrew handoff declared by +`base_manifest.yaml`. It does not create tags, publish GitHub Releases, or edit +Homebrew tap repositories yet. + Use `--keep-last ` to retain the newest log files per CLI log directory while pruning older logs. This retention mode applies only to `*.log` files; temp and cache artifacts continue to use `--older-than`. @@ -1147,7 +1165,7 @@ Base follows a few simple principles. Base `0.3.0` is the current release. The implemented command surface covers setup, checks, diagnostics, project discovery, project activation, project test execution, mise integration, cleanup, updates, onboarding, repository baseline -creation, and GitHub workflow helpers. +creation, release readiness inspection, and GitHub workflow helpers. For the documentation map and naming convention, see [docs/README.md](docs/README.md). For the architecture and product direction, diff --git a/base_manifest.yaml b/base_manifest.yaml index 766c482..be0158c 100644 --- a/base_manifest.yaml +++ b/base_manifest.yaml @@ -12,6 +12,19 @@ demo: script: ./demo/demo.sh description: Self-contained walkthrough of Base's core project workflow +release: + version_file: VERSION + changelog: CHANGELOG.md + tag_prefix: v + github: + repository: codeforester/base + release_title: "Base v{version}" + homebrew: + required: true + tap_repository: codeforester/homebrew-base + formula_path: Formula/base.rb + package: codeforester/base/base + # Optional relative path to a Homebrew Brewfile for ordinary macOS tools. # Base runs `brew bundle --file=` during setup when this is set. # Keep Base-specific managed dependencies in `artifacts`; use Brewfile for diff --git a/cli/bash/commands/basectl/basectl.sh b/cli/bash/commands/basectl/basectl.sh index 97976ce..2238931 100644 --- a/cli/bash/commands/basectl/basectl.sh +++ b/cli/bash/commands/basectl/basectl.sh @@ -27,6 +27,8 @@ Commands: Create repository baselines, agent guidance, and project installer templates. ci [options] Run Base setup, checks, and diagnostics in non-interactive CI. + release --version [options] + Inspect release readiness, plan, and changelog notes without publishing. clean [--older-than ] [--keep-last ] [options] Remove old Base CLI runtime logs, temp files, and cache entries. logs [options] @@ -204,6 +206,11 @@ basectl_do_ci() { base_ci_subcommand_main "$@" } +basectl_do_release() { + basectl_source_subcommand_module release || return 1 + base_release_subcommand_main "$@" +} + basectl_do_clean() { basectl_source_subcommand_module clean || return 1 base_clean_subcommand_main "$@" @@ -358,6 +365,7 @@ basectl_main() { run) basectl_do_run "$@" ;; repo) basectl_do_repo "$@" ;; ci) basectl_do_ci "$@" ;; + release) basectl_do_release "$@" ;; clean) basectl_do_clean "$@" ;; logs) basectl_do_logs "$@" ;; config) basectl_do_config "$@" ;; diff --git a/cli/bash/commands/basectl/subcommands/release.sh b/cli/bash/commands/basectl/subcommands/release.sh new file mode 100644 index 0000000..b5d1911 --- /dev/null +++ b/cli/bash/commands/basectl/subcommands/release.sh @@ -0,0 +1,42 @@ +# shellcheck shell=bash +[[ -n "${_base_release_subcommand_sourced:-}" ]] && return +_base_release_subcommand_sourced=1 +readonly _base_release_subcommand_sourced + +base_release_subcommand_usage() { + cat <<'EOF' +Usage: + basectl release check --version [options] + basectl release plan --version [options] + basectl release notes --version [options] + +Options: + --version Release version to inspect. + --manifest Use a specific base_manifest.yaml path. + -h, --help Show this help text. + +Inspect release readiness, plan, and changelog notes without creating tags, +publishing GitHub Releases, or editing Homebrew taps. +EOF +} + +base_release_subcommand_main() { + local release_command="${1:-}" + local wrapper="$BASE_HOME/bin/base-wrapper" + + case "$release_command" in + ""|-h|--help|help) + base_release_subcommand_usage + return 0 + ;; + check|plan|notes) + ;; + *) + base_release_subcommand_usage >&2 + fatal_error "Unknown release command '$release_command'." + ;; + esac + + [[ -x "$wrapper" ]] || fatal_error "Base Python wrapper '$wrapper' is missing or is not executable." + "$wrapper" --project base base_release "$@" +} diff --git a/cli/bash/commands/basectl/tests/help.bats b/cli/bash/commands/basectl/tests/help.bats index 246e75c..af1abd7 100644 --- a/cli/bash/commands/basectl/tests/help.bats +++ b/cli/bash/commands/basectl/tests/help.bats @@ -15,6 +15,7 @@ load ./basectl_helpers.bash [[ "$output" == *"run [options]"* ]] [[ "$output" == *"repo [options]"* ]] [[ "$output" == *"ci [options]"* ]] + [[ "$output" == *"release --version [options]"* ]] [[ "$output" == *"clean [--older-than ] [--keep-last ] [options]"* ]] [[ "$output" == *"logs [options]"* ]] [[ "$output" == *"config "* ]] @@ -52,6 +53,7 @@ load ./basectl_helpers.bash grep -Fqx ' run [options]' <<<"$output" grep -Fqx ' repo [options]' <<<"$output" grep -Fqx ' ci [options]' <<<"$output" + grep -Fqx ' release --version [options]' <<<"$output" grep -Fqx ' logs [options]' <<<"$output" grep -Fqx ' workspace [options]' <<<"$output" [[ "$output" != *"-b DIR"* ]] diff --git a/cli/bash/commands/basectl/tests/release.bats b/cli/bash/commands/basectl/tests/release.bats new file mode 100644 index 0000000..da0fa1d --- /dev/null +++ b/cli/bash/commands/basectl/tests/release.bats @@ -0,0 +1,43 @@ +#!/usr/bin/env bats + +load ./basectl_helpers.bash + + +@test "basectl release prints help without requiring the Base Python venv" { + run_basectl release --help + + [ "$status" -eq 0 ] + [[ "$output" == *"Usage:"* ]] + [[ "$output" == *"basectl release check --version "* ]] + [[ "$output" == *"basectl release plan --version "* ]] + [[ "$output" == *"basectl release notes --version "* ]] +} + +@test "basectl release delegates to the Python release layer" { + local python_bin="$TEST_HOME/.base.d/base/.venv/bin/python" + local manifest="$TEST_TMPDIR/base_manifest.yaml" + + mkdir -p "$(dirname "$python_bin")" + printf 'project:\n name: demo\nartifacts: []\n' > "$manifest" + cat > "$python_bin" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == "-m" && "${2:-}" == "base_release" ]]; then + printf 'BASE_PROJECT=%s\n' "$BASE_PROJECT" > "${BASE_TEST_RELEASE_STATE:?}" + printf 'ARGS=%s\n' "${*:3}" + exit 0 +fi +printf 'unexpected release python args: %s\n' "$*" >&2 +exit 1 +EOF + chmod +x "$python_bin" + + run env \ + HOME="$TEST_HOME" \ + PATH="/usr/bin:/bin:/usr/sbin:/sbin" \ + BASE_TEST_RELEASE_STATE="$TEST_TMPDIR/release-state" \ + "$BASE_REPO_ROOT/bin/basectl" release plan --version 1.2.3 --manifest "$manifest" + + [ "$status" -eq 0 ] + [ "$output" = "ARGS=plan --version 1.2.3 --manifest $manifest" ] + [ "$(cat "$TEST_TMPDIR/release-state")" = "BASE_PROJECT=base" ] +} diff --git a/cli/python/base_release/__init__.py b/cli/python/base_release/__init__.py new file mode 100644 index 0000000..176498e --- /dev/null +++ b/cli/python/base_release/__init__.py @@ -0,0 +1 @@ +"""Read-only release assistant for Base-managed projects.""" diff --git a/cli/python/base_release/__main__.py b/cli/python/base_release/__main__.py new file mode 100644 index 0000000..edd6f8a --- /dev/null +++ b/cli/python/base_release/__main__.py @@ -0,0 +1,5 @@ +from .engine import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cli/python/base_release/engine.py b/cli/python/base_release/engine.py new file mode 100644 index 0000000..494b4b4 --- /dev/null +++ b/cli/python/base_release/engine.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +import base_cli +from base_setup.manifest import BaseManifest +from base_setup.manifest import ManifestError +from base_setup.manifest import ReleaseConfig +from base_setup.manifest import read_manifest + + +app = base_cli.App(name="base_release") +CHANGELOG_HEADER_RE = re.compile(r"^##\s+(?:\[(?P[^\]]+)\]|(?P\S+))(?:\s+-.*)?$") + + +class ReleaseUsageError(RuntimeError): + pass + + +class ReleaseError(RuntimeError): + pass + + +@dataclass(frozen=True) +class ReleaseArguments: + command: str + version: str + manifest_path: Path | None + + +@dataclass(frozen=True) +class ReleaseContext: + manifest_path: Path + manifest: BaseManifest + release: ReleaseConfig + version: str + tag_name: str + version_file: Path + changelog: Path + + +@dataclass(frozen=True) +class ReleaseFinding: + status: str + name: str + message: str + + +def main(argv: list[str] | None = None) -> int: + result = app.click_command.main(args=argv, standalone_mode=False) + return int(result or 0) + + +@app.command( + context_settings={ + "allow_extra_args": True, + "help_option_names": ["-h", "--help"], + "ignore_unknown_options": True, + } +) +@base_cli.argument("arguments", nargs=-1) +def run(ctx: base_cli.Context, arguments: tuple[str, ...]) -> int: + try: + args = parse_release_args(arguments) + release_context = build_release_context(ctx, args) + if args.command == "check": + return release_check_command(release_context) + if args.command == "plan": + return release_plan_command(release_context) + if args.command == "notes": + return release_notes_command(release_context) + raise ReleaseUsageError(f"Unknown release command '{args.command}'.") + except ReleaseUsageError as exc: + print_usage(file=sys.stderr) + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + except (ManifestError, ReleaseError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + +def parse_release_args(arguments: tuple[str, ...]) -> ReleaseArguments: + if not arguments or arguments[0] in ("-h", "--help", "help"): + print_usage() + raise SystemExit(0) + + command = arguments[0] + if command not in ("check", "plan", "notes"): + raise ReleaseUsageError(f"Unknown release command '{command}'.") + + version: str | None = None + manifest_path: Path | None = None + remaining = list(arguments[1:]) + index = 0 + while index < len(remaining): + arg = remaining[index] + if arg in ("-h", "--help"): + print_usage() + raise SystemExit(0) + if arg == "--version": + index += 1 + if index >= len(remaining) or not remaining[index]: + raise ReleaseUsageError("Option '--version' requires an argument.") + version = remaining[index] + elif arg == "--manifest": + index += 1 + if index >= len(remaining) or not remaining[index]: + raise ReleaseUsageError("Option '--manifest' requires an argument.") + manifest_path = Path(remaining[index]).expanduser() + else: + raise ReleaseUsageError(f"Unknown release {command} option '{arg}'.") + index += 1 + + if version is None: + raise ReleaseUsageError(f"The 'release {command}' command requires --version.") + return ReleaseArguments(command=command, version=version, manifest_path=manifest_path) + + +def print_usage(file=sys.stdout) -> None: + print( + """Usage: + base_release check --version [--manifest ] + base_release plan --version [--manifest ] + base_release notes --version [--manifest ] + +Purpose: + Inspect release readiness for a Base-managed project without publishing tags, + GitHub Releases, or Homebrew tap changes.""", + file=file, + ) + + +def build_release_context(ctx: base_cli.Context, args: ReleaseArguments) -> ReleaseContext: + manifest_path = args.manifest_path or ctx.manifest_path + if manifest_path is None: + raise ReleaseError("No base_manifest.yaml was found. Pass --manifest .") + manifest_path = manifest_path.resolve() + manifest = read_manifest(manifest_path) + if manifest.release is None: + raise ReleaseError(f"{manifest_path}: manifest does not declare release metadata.") + + project_root = manifest_path.parent + release = manifest.release + return ReleaseContext( + manifest_path=manifest_path, + manifest=manifest, + release=release, + version=args.version, + tag_name=f"{release.tag_prefix}{args.version}", + version_file=project_root / release.version_file, + changelog=project_root / release.changelog, + ) + + +def release_check_command(ctx: ReleaseContext) -> int: + findings = release_findings(ctx) + print(f"\nRelease check for {ctx.manifest.project_name} v{ctx.version}\n") + for finding in findings: + print(f"{finding.status:<5} {finding.name:<14} {finding.message}") + if any(finding.status == "error" for finding in findings): + return 1 + return 0 + + +def release_plan_command(ctx: ReleaseContext) -> int: + title = render_release_title(ctx) + print(f"Release plan for {ctx.manifest.project_name} v{ctx.version}") + print("") + print(f"Version file: {ctx.release.version_file}") + print(f"Changelog: {ctx.release.changelog}") + print(f"Tag: {ctx.tag_name}") + print(f"GitHub repository: {ctx.release.github.repository}") + print(f"GitHub release title: {title}") + if ctx.release.homebrew is not None and ctx.release.homebrew.required: + archive_url = github_tag_archive_url(ctx.release.github.repository, ctx.tag_name) + print("") + print("Homebrew handoff required:") + print(f" Tap repository: {ctx.release.homebrew.tap_repository}") + print(f" Formula path: {ctx.release.homebrew.formula_path}") + print(f" Package: {ctx.release.homebrew.package}") + print(f" Archive URL: {archive_url}") + print(f" SHA256 command: curl -fsSL {archive_url} | shasum -a 256") + else: + print("") + print("Homebrew handoff: not declared") + return 0 + + +def release_notes_command(ctx: ReleaseContext) -> int: + print(extract_changelog_section(ctx.changelog, ctx.version)) + return 0 + + +def release_findings(ctx: ReleaseContext) -> tuple[ReleaseFinding, ...]: + findings: list[ReleaseFinding] = [ + ReleaseFinding("ok", "manifest", f"Release metadata found in {ctx.manifest_path}."), + version_file_finding(ctx), + changelog_finding(ctx), + git_worktree_finding(ctx.manifest_path.parent), + git_branch_finding(ctx.manifest_path.parent), + gh_cli_finding(), + local_tag_finding(ctx.manifest_path.parent, ctx.tag_name), + remote_tag_finding(ctx.manifest_path.parent, ctx.tag_name), + ] + return tuple(findings) + + +def version_file_finding(ctx: ReleaseContext) -> ReleaseFinding: + version = read_version_file(ctx.version_file) + if version is None: + return ReleaseFinding("error", "version_file", f"{ctx.release.version_file} is missing or empty.") + if version != ctx.version: + return ReleaseFinding( + "error", + "version_file", + f"{ctx.release.version_file} contains {version}, expected {ctx.version}.", + ) + return ReleaseFinding("ok", "version_file", f"{ctx.release.version_file} matches {ctx.version}.") + + +def changelog_finding(ctx: ReleaseContext) -> ReleaseFinding: + try: + extract_changelog_section(ctx.changelog, ctx.version) + except ReleaseError as exc: + return ReleaseFinding("error", "changelog", str(exc)) + return ReleaseFinding("ok", "changelog", f"{ctx.release.changelog} has a section for {ctx.version}.") + + +def git_worktree_finding(root: Path) -> ReleaseFinding: + status = git_status(root) + if status is None: + return ReleaseFinding("warn", "git", "Unable to inspect Git worktree status.") + if status: + return ReleaseFinding("error", "git", "Git worktree has tracked or untracked changes.") + return ReleaseFinding("ok", "git", "Git worktree is clean.") + + +def git_branch_finding(root: Path) -> ReleaseFinding: + branch = current_git_branch(root) + if branch is None: + return ReleaseFinding("warn", "branch", "Unable to inspect current Git branch.") + if not branch: + return ReleaseFinding("warn", "branch", "Git worktree is detached from a branch.") + return ReleaseFinding("ok", "branch", f"Current branch is {branch}.") + + +def local_tag_finding(root: Path, tag_name: str) -> ReleaseFinding: + if local_tag_exists(root, tag_name): + return ReleaseFinding("error", "local_tag", f"Local tag {tag_name} already exists.") + return ReleaseFinding("ok", "local_tag", f"Local tag {tag_name} is available.") + + +def read_version_file(path: Path) -> str | None: + try: + for line in path.read_text(encoding="utf-8").splitlines(): + value = line.strip() + if value: + return value + except OSError: + return None + return None + + +def extract_changelog_section(path: Path, version: str) -> str: + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError as exc: + raise ReleaseError(f"{path.name} could not be read: {exc}") from exc + + start: int | None = None + for index, line in enumerate(lines): + match = CHANGELOG_HEADER_RE.match(line) + if match and version in (match.group("bracket"), match.group("plain")): + start = index + 1 + break + if start is None: + raise ReleaseError(f"{path.name} has no section for {version}.") + + end = len(lines) + for index in range(start, len(lines)): + if lines[index].startswith("## "): + end = index + break + + section_lines = lines[start:end] + while section_lines and not section_lines[0].strip(): + section_lines.pop(0) + while section_lines and not section_lines[-1].strip(): + section_lines.pop() + if not section_lines: + raise ReleaseError(f"{path.name} section for {version} is empty.") + return "\n".join(section_lines) + + +def git_status(root: Path) -> str | None: + result = subprocess.run( + ["git", "status", "--porcelain"], + cwd=root, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + if result.returncode != 0: + return None + return result.stdout.strip() + + +def current_git_branch(root: Path) -> str | None: + result = subprocess.run( + ["git", "branch", "--show-current"], + cwd=root, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + if result.returncode != 0: + return None + return result.stdout.strip() + + +def gh_cli_finding() -> ReleaseFinding: + if shutil.which("gh") is None: + return ReleaseFinding("error", "gh", "GitHub CLI 'gh' was not found.") + + try: + result = subprocess.run( + ["gh", "auth", "status", "-h", "github.com"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return ReleaseFinding("error", "gh", f"Unable to run GitHub CLI auth check: {exc}.") + if result.returncode == 0: + return ReleaseFinding("ok", "gh", "GitHub CLI is authenticated for github.com.") + + detail = last_non_empty_line(result.stdout) + if detail: + return ReleaseFinding("error", "gh", f"GitHub CLI auth check failed: {detail}") + return ReleaseFinding("error", "gh", "GitHub CLI is not authenticated for github.com.") + + +def local_tag_exists(root: Path, tag_name: str) -> bool: + result = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", f"refs/tags/{tag_name}"], + cwd=root, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return result.returncode == 0 + + +def remote_tag_finding(root: Path, tag_name: str) -> ReleaseFinding: + try: + result = subprocess.run( + ["git", "ls-remote", "--tags", "origin", f"refs/tags/{tag_name}"], + cwd=root, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return ReleaseFinding("error", "remote_tag", f"Unable to inspect remote tag {tag_name} on origin: {exc}.") + + if result.returncode != 0: + detail = last_non_empty_line(result.stderr) + if detail: + return ReleaseFinding("error", "remote_tag", f"Unable to inspect remote tag {tag_name} on origin: {detail}") + return ReleaseFinding("error", "remote_tag", f"Unable to inspect remote tag {tag_name} on origin.") + if result.stdout.strip(): + return ReleaseFinding("error", "remote_tag", f"Remote tag {tag_name} already exists on origin.") + return ReleaseFinding("ok", "remote_tag", f"Remote tag {tag_name} is available on origin.") + + +def last_non_empty_line(value: str) -> str | None: + for line in reversed(value.splitlines()): + stripped = line.strip() + if stripped: + return stripped + return None + + +def render_release_title(ctx: ReleaseContext) -> str: + return ctx.release.github.release_title.format( + repository=ctx.release.github.repository, + version=ctx.version, + tag=ctx.tag_name, + ) + + +def github_tag_archive_url(repository: str, tag_name: str) -> str: + return f"https://github.com/{repository}/archive/refs/tags/{tag_name}.tar.gz" diff --git a/cli/python/base_release/tests/test_engine.py b/cli/python/base_release/tests/test_engine.py new file mode 100644 index 0000000..ddf10d1 --- /dev/null +++ b/cli/python/base_release/tests/test_engine.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import io +import os +import subprocess +import tempfile +import unittest +from contextlib import contextmanager +from contextlib import redirect_stderr +from contextlib import redirect_stdout +from pathlib import Path +from unittest import mock + +from base_release.engine import ReleaseFinding +from base_release.engine import main + + +@contextmanager +def pushd(path: Path): + old_cwd = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(old_cwd) + + +def run_engine(args: list[str], cwd: Path) -> tuple[int, str, str]: + stdout = io.StringIO() + stderr = io.StringIO() + with tempfile.TemporaryDirectory() as home_dir: + with mock.patch.dict( + os.environ, + {"BASE_HOME": str(Path(__file__).resolve().parents[4]), "HOME": home_dir}, + ): + with pushd(cwd), redirect_stdout(stdout), redirect_stderr(stderr): + status = main(args) + return status, stdout.getvalue(), stderr.getvalue() + + +def write_release_project( + root: Path, + *, + version_file_content: str = "1.2.3\n", + changelog: str | None = None, + homebrew: bool = True, +) -> Path: + changelog_content = changelog or "\n".join( + [ + "# Changelog", + "", + "## [Unreleased]", + "", + "## [1.2.3] - 2026-06-09", + "", + "- Added the release assistant.", + "", + "## [1.2.2] - 2026-06-01", + "", + "- Previous release.", + ] + ) + root.joinpath("VERSION").write_text(version_file_content, encoding="utf-8") + root.joinpath("CHANGELOG.md").write_text(changelog_content, encoding="utf-8") + manifest_lines = [ + "project:", + " name: demo", + "", + "release:", + " version_file: VERSION", + " changelog: CHANGELOG.md", + " tag_prefix: v", + " github:", + " repository: codeforester/demo", + " release_title: \"Demo v{version}\"", + ] + if homebrew: + manifest_lines.extend( + [ + " homebrew:", + " required: true", + " tap_repository: codeforester/homebrew-demo", + " formula_path: Formula/demo.rb", + " package: codeforester/demo/demo", + ] + ) + manifest_lines.extend(["", "artifacts: []"]) + manifest_path = root / "base_manifest.yaml" + manifest_path.write_text("\n".join(manifest_lines), encoding="utf-8") + subprocess.run(["git", "init"], cwd=root, check=True, stdout=subprocess.DEVNULL) + subprocess.run(["git", "config", "user.email", "base@example.com"], cwd=root, check=True) + subprocess.run(["git", "config", "user.name", "Base Tests"], cwd=root, check=True) + subprocess.run(["git", "add", "."], cwd=root, check=True) + subprocess.run(["git", "commit", "-m", "initial"], cwd=root, check=True, stdout=subprocess.DEVNULL) + return manifest_path + + +def add_origin(root: Path) -> None: + remote_path = root.parent / "remote.git" + subprocess.run(["git", "init", "--bare", str(remote_path)], check=True, stdout=subprocess.DEVNULL) + subprocess.run(["git", "remote", "add", "origin", str(remote_path)], cwd=root, check=True) + subprocess.run(["git", "push", "origin", "HEAD:main"], cwd=root, check=True, stdout=subprocess.DEVNULL) + + +def add_origin_with_remote_tag(root: Path, tag_name: str) -> None: + add_origin(root) + subprocess.run(["git", "tag", tag_name], cwd=root, check=True) + subprocess.run(["git", "push", "origin", tag_name], cwd=root, check=True, stdout=subprocess.DEVNULL) + subprocess.run(["git", "tag", "-d", tag_name], cwd=root, check=True, stdout=subprocess.DEVNULL) + + +class ReleaseEngineTests(unittest.TestCase): + + def test_notes_prints_changelog_section_for_version(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + manifest_path = write_release_project(root) + + status, stdout, stderr = run_engine( + ["notes", "--version", "1.2.3", "--manifest", str(manifest_path)], + root, + ) + + self.assertEqual(status, 0, stderr) + self.assertIn("Added the release assistant.", stdout) + self.assertNotIn("Previous release.", stdout) + + + def test_plan_prints_github_and_homebrew_handoff(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + manifest_path = write_release_project(root) + + status, stdout, stderr = run_engine( + ["plan", "--version", "1.2.3", "--manifest", str(manifest_path)], + root, + ) + + self.assertEqual(status, 0, stderr) + self.assertIn("Release plan for demo v1.2.3", stdout) + self.assertIn("Tag: v1.2.3", stdout) + self.assertIn("GitHub repository: codeforester/demo", stdout) + self.assertIn("GitHub release title: Demo v1.2.3", stdout) + self.assertIn("Homebrew handoff required", stdout) + self.assertIn("Tap repository: codeforester/homebrew-demo", stdout) + self.assertIn("Formula path: Formula/demo.rb", stdout) + self.assertIn("Package: codeforester/demo/demo", stdout) + self.assertIn( + "curl -fsSL https://github.com/codeforester/demo/archive/refs/tags/v1.2.3.tar.gz | shasum -a 256", + stdout, + ) + + + def test_check_fails_when_version_file_does_not_match(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + manifest_path = write_release_project(root, version_file_content="1.2.2\n") + + status, stdout, stderr = run_engine( + ["check", "--version", "1.2.3", "--manifest", str(manifest_path)], + root, + ) + + self.assertEqual(status, 1) + self.assertIn("VERSION contains 1.2.2, expected 1.2.3", stdout) + self.assertEqual(stderr, "") + + + def test_check_fails_when_changelog_section_is_missing(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + manifest_path = write_release_project( + root, + changelog="# Changelog\n\n## [1.2.2] - 2026-06-01\n\n- Previous release.\n", + ) + + status, stdout, stderr = run_engine( + ["check", "--version", "1.2.3", "--manifest", str(manifest_path)], + root, + ) + + self.assertEqual(status, 1) + self.assertIn("CHANGELOG.md has no section for 1.2.3", stdout) + self.assertEqual(stderr, "") + + + def test_check_passes_for_clean_release_ready_project(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "project" + root.mkdir() + manifest_path = write_release_project(root) + add_origin(root) + + with mock.patch( + "base_release.engine.gh_cli_finding", + return_value=ReleaseFinding("ok", "gh", "GitHub CLI is authenticated for github.com."), + ): + status, stdout, stderr = run_engine( + ["check", "--version", "1.2.3", "--manifest", str(manifest_path)], + root, + ) + + self.assertEqual(status, 0, stdout + stderr) + self.assertIn("Git worktree is clean.", stdout) + self.assertIn("Local tag v1.2.3 is available.", stdout) + self.assertIn("Remote tag v1.2.3 is available on origin.", stdout) + self.assertEqual(stderr, "") + + + def test_check_fails_when_worktree_is_dirty(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "project" + root.mkdir() + manifest_path = write_release_project(root) + add_origin(root) + root.joinpath("scratch.txt").write_text("dirty\n", encoding="utf-8") + + with mock.patch( + "base_release.engine.gh_cli_finding", + return_value=ReleaseFinding("ok", "gh", "GitHub CLI is authenticated for github.com."), + ): + status, stdout, stderr = run_engine( + ["check", "--version", "1.2.3", "--manifest", str(manifest_path)], + root, + ) + + self.assertEqual(status, 1) + self.assertIn("Git worktree has tracked or untracked changes.", stdout) + self.assertEqual(stderr, "") + + + def test_check_fails_when_local_tag_already_exists(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "project" + root.mkdir() + manifest_path = write_release_project(root) + add_origin(root) + subprocess.run(["git", "tag", "v1.2.3"], cwd=root, check=True) + + with mock.patch( + "base_release.engine.gh_cli_finding", + return_value=ReleaseFinding("ok", "gh", "GitHub CLI is authenticated for github.com."), + ): + status, stdout, stderr = run_engine( + ["check", "--version", "1.2.3", "--manifest", str(manifest_path)], + root, + ) + + self.assertEqual(status, 1) + self.assertIn("Local tag v1.2.3 already exists.", stdout) + self.assertEqual(stderr, "") + + + def test_check_fails_when_remote_tag_already_exists(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "project" + root.mkdir() + manifest_path = write_release_project(root) + add_origin_with_remote_tag(root, "v1.2.3") + + with mock.patch( + "base_release.engine.gh_cli_finding", + return_value=ReleaseFinding("ok", "gh", "GitHub CLI is authenticated for github.com."), + ): + status, stdout, stderr = run_engine( + ["check", "--version", "1.2.3", "--manifest", str(manifest_path)], + root, + ) + + self.assertEqual(status, 1) + self.assertIn("Remote tag v1.2.3 already exists on origin.", stdout) + self.assertEqual(stderr, "") diff --git a/cli/python/base_setup/manifest.py b/cli/python/base_setup/manifest.py index 52ca14e..88545f4 100644 --- a/cli/python/base_setup/manifest.py +++ b/cli/python/base_setup/manifest.py @@ -23,6 +23,8 @@ CURRENT_MANIFEST_SCHEMA_VERSION = 1 ENVIRONMENT_VARIABLE_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") COMMAND_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]*$") +GITHUB_REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +HOMEBREW_PACKAGE_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/[A-Za-z0-9_.+-]+$") PORT_HEALTH_STATES = {"free", "listening"} @@ -57,6 +59,29 @@ class DemoConfig: description: str | None = None +@dataclass(frozen=True) +class ReleaseGithubConfig: + repository: str + release_title: str + + +@dataclass(frozen=True) +class ReleaseHomebrewConfig: + required: bool + tap_repository: str | None = None + formula_path: str | None = None + package: str | None = None + + +@dataclass(frozen=True) +class ReleaseConfig: + version_file: str + changelog: str + tag_prefix: str + github: ReleaseGithubConfig + homebrew: ReleaseHomebrewConfig | None = None + + @dataclass(frozen=True) class BuildTargetConfig: command: str @@ -104,6 +129,7 @@ class BaseManifest: activate: ActivateConfig = field(default_factory=ActivateConfig) demo: DemoConfig | None = None build: BuildConfig | None = None + release: ReleaseConfig | None = None def read_manifest(path: Path) -> BaseManifest: @@ -136,6 +162,7 @@ def read_manifest(path: Path) -> BaseManifest: "activate", "demo", "build", + "release", } unknown_top_level = sorted(set(data) - allowed_top_level) if unknown_top_level: @@ -152,6 +179,7 @@ def read_manifest(path: Path) -> BaseManifest: activate = _read_activate(path, data.get("activate")) demo = _read_demo(path, data.get("demo")) build = _read_build(path, data.get("build")) + release = _read_release(path, data.get("release")) artifacts = _read_artifacts(path, data.get("artifacts", [])) return BaseManifest( @@ -168,6 +196,7 @@ def read_manifest(path: Path) -> BaseManifest: activate=activate, demo=demo, build=build, + release=release, ) @@ -304,6 +333,157 @@ def _read_build(path: Path, build_data: Any) -> BuildConfig | None: return BuildConfig(default=default, targets=targets) +def _read_release(path: Path, release_data: Any) -> ReleaseConfig | None: + if release_data is None: + return None + if not isinstance(release_data, dict): + raise ManifestError(f"{path}: release must be a mapping when provided.") + + allowed_keys = {"version_file", "changelog", "tag_prefix", "github", "homebrew"} + unknown_keys = sorted(set(release_data) - allowed_keys) + if unknown_keys: + raise ManifestError(f"{path}: release has unsupported keys: {', '.join(unknown_keys)}.") + + version_file = _read_release_relative_path( + path, + "release.version_file", + release_data.get("version_file", "VERSION"), + ) + changelog = _read_release_relative_path( + path, + "release.changelog", + release_data.get("changelog", "CHANGELOG.md"), + ) + tag_prefix = _read_release_string(path, "release.tag_prefix", release_data.get("tag_prefix", "v")) + github = _read_release_github(path, release_data.get("github")) + homebrew = _read_release_homebrew(path, release_data.get("homebrew")) + + return ReleaseConfig( + version_file=version_file, + changelog=changelog, + tag_prefix=tag_prefix, + github=github, + homebrew=homebrew, + ) + + +def _read_release_github(path: Path, github_data: Any) -> ReleaseGithubConfig: + if not isinstance(github_data, dict): + raise ManifestError(f"{path}: release.github must be a mapping.") + + allowed_keys = {"repository", "release_title"} + unknown_keys = sorted(set(github_data) - allowed_keys) + if unknown_keys: + raise ManifestError(f"{path}: release.github has unsupported keys: {', '.join(unknown_keys)}.") + + repository = _read_release_repository(path, "release.github.repository", github_data.get("repository")) + release_title = _read_release_string( + path, + "release.github.release_title", + github_data.get("release_title", "{repository} v{version}"), + ) + return ReleaseGithubConfig(repository=repository, release_title=release_title) + + +def _read_release_homebrew(path: Path, homebrew_data: Any) -> ReleaseHomebrewConfig | None: + if homebrew_data is None: + return None + if not isinstance(homebrew_data, dict): + raise ManifestError(f"{path}: release.homebrew must be a mapping when provided.") + + allowed_keys = {"required", "tap_repository", "formula_path", "package"} + unknown_keys = sorted(set(homebrew_data) - allowed_keys) + if unknown_keys: + raise ManifestError(f"{path}: release.homebrew has unsupported keys: {', '.join(unknown_keys)}.") + + required = homebrew_data.get("required", True) + if not isinstance(required, bool): + raise ManifestError(f"{path}: release.homebrew.required must be a boolean when provided.") + + tap_repository = _read_optional_release_repository( + path, + "release.homebrew.tap_repository", + homebrew_data.get("tap_repository"), + ) + formula_path = _read_optional_release_relative_path( + path, + "release.homebrew.formula_path", + homebrew_data.get("formula_path"), + ) + package = _read_optional_homebrew_package( + path, + "release.homebrew.package", + homebrew_data.get("package"), + ) + + if required: + missing = [ + field_name + for field_name, field_value in ( + ("tap_repository", tap_repository), + ("formula_path", formula_path), + ("package", package), + ) + if field_value is None + ] + if missing: + raise ManifestError( + f"{path}: release.homebrew required fields are missing: {', '.join(missing)}." + ) + + return ReleaseHomebrewConfig( + required=required, + tap_repository=tap_repository, + formula_path=formula_path, + package=package, + ) + + +def _read_release_repository(path: Path, field_name: str, value: Any) -> str: + repository = _read_release_string(path, field_name, value) + if not GITHUB_REPOSITORY_RE.fullmatch(repository): + raise ManifestError(f"{path}: {field_name} must use owner/name format.") + return repository + + +def _read_optional_release_repository(path: Path, field_name: str, value: Any) -> str | None: + if value is None: + return None + return _read_release_repository(path, field_name, value) + + +def _read_optional_homebrew_package(path: Path, field_name: str, value: Any) -> str | None: + if value is None: + return None + package = _read_release_string(path, field_name, value) + if not HOMEBREW_PACKAGE_RE.fullmatch(package): + raise ManifestError(f"{path}: {field_name} must use owner/tap/formula format.") + return package + + +def _read_release_relative_path(path: Path, field_name: str, value: Any) -> str: + release_path = _read_release_string(path, field_name, value) + parsed_path = Path(release_path) + if parsed_path.is_absolute() or ".." in parsed_path.parts: + raise ManifestError(f"{path}: {field_name} must be a relative path inside the project.") + return release_path + + +def _read_optional_release_relative_path(path: Path, field_name: str, value: Any) -> str | None: + if value is None: + return None + return _read_release_relative_path(path, field_name, value) + + +def _read_release_string(path: Path, field_name: str, value: Any) -> str: + if not isinstance(value, str) or not value.strip(): + raise ManifestError(f"{path}: {field_name} must be a non-empty string.") + value = value.strip() + if _has_control_line_break(value): + raise ManifestError(f"{path}: {field_name} must not contain control line breaks.") + return value + + def _read_build_default( path: Path, default_data: Any, diff --git a/cli/python/base_setup/tests/test_manifest.py b/cli/python/base_setup/tests/test_manifest.py index bbf7acf..a5136d0 100644 --- a/cli/python/base_setup/tests/test_manifest.py +++ b/cli/python/base_setup/tests/test_manifest.py @@ -1,5 +1,7 @@ from __future__ import annotations +# pylint: disable=too-many-public-methods + import tempfile import unittest from pathlib import Path @@ -207,6 +209,148 @@ def test_reads_manifest_demo_config(self) -> None: self.assertEqual(manifest.demo.description, "Interactive project walkthrough") + def test_reads_manifest_release_config(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + manifest_path = Path(tmpdir) / "base_manifest.yaml" + manifest_path.write_text( + "\n".join( + [ + "project:", + " name: demo", + "", + "release:", + " version_file: VERSION", + " changelog: CHANGELOG.md", + " tag_prefix: v", + " github:", + " repository: codeforester/base", + " release_title: \"Base v{version}\"", + " homebrew:", + " required: true", + " tap_repository: codeforester/homebrew-base", + " formula_path: Formula/base.rb", + " package: codeforester/base/base", + "", + "artifacts: []", + ] + ), + encoding="utf-8", + ) + + manifest = read_manifest(manifest_path) + + self.assertIsNotNone(manifest.release) + assert manifest.release is not None + self.assertEqual(manifest.release.version_file, "VERSION") + self.assertEqual(manifest.release.changelog, "CHANGELOG.md") + self.assertEqual(manifest.release.tag_prefix, "v") + self.assertEqual(manifest.release.github.repository, "codeforester/base") + self.assertEqual(manifest.release.github.release_title, "Base v{version}") + self.assertIsNotNone(manifest.release.homebrew) + assert manifest.release.homebrew is not None + self.assertTrue(manifest.release.homebrew.required) + self.assertEqual(manifest.release.homebrew.tap_repository, "codeforester/homebrew-base") + self.assertEqual(manifest.release.homebrew.formula_path, "Formula/base.rb") + self.assertEqual(manifest.release.homebrew.package, "codeforester/base/base") + + + def test_reads_manifest_release_config_without_homebrew(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + manifest_path = Path(tmpdir) / "base_manifest.yaml" + manifest_path.write_text( + "\n".join( + [ + "project:", + " name: demo", + "", + "release:", + " github:", + " repository: codeforester/demo", + "", + "artifacts: []", + ] + ), + encoding="utf-8", + ) + + manifest = read_manifest(manifest_path) + + self.assertIsNotNone(manifest.release) + assert manifest.release is not None + self.assertEqual(manifest.release.version_file, "VERSION") + self.assertEqual(manifest.release.changelog, "CHANGELOG.md") + self.assertEqual(manifest.release.tag_prefix, "v") + self.assertEqual(manifest.release.github.repository, "codeforester/demo") + self.assertEqual(manifest.release.github.release_title, "{repository} v{version}") + self.assertIsNone(manifest.release.homebrew) + + + def test_rejects_invalid_manifest_release_config(self) -> None: + invalid_values = { + "scalar": "release: true", + "unknown_key": "release:\n github:\n repository: codeforester/base\n package: base", + "missing_github": "release:\n version_file: VERSION", + "github_scalar": "release:\n github: codeforester/base", + "missing_repository": "release:\n github:\n release_title: Base", + "invalid_repository": "release:\n github:\n repository: codeforester", + "absolute_version_file": ( + "release:\n version_file: /tmp/VERSION\n github:\n repository: codeforester/base" + ), + "absolute_changelog": ( + "release:\n changelog: /tmp/CHANGELOG.md\n github:\n repository: codeforester/base" + ), + "empty_tag_prefix": "release:\n tag_prefix: ''\n github:\n repository: codeforester/base", + "homebrew_scalar": "release:\n github:\n repository: codeforester/base\n homebrew: true", + "homebrew_required_missing_tap": ( + "release:\n" + " github:\n" + " repository: codeforester/base\n" + " homebrew:\n" + " required: true\n" + " formula_path: Formula/base.rb\n" + " package: codeforester/base/base" + ), + "homebrew_absolute_formula": ( + "release:\n" + " github:\n" + " repository: codeforester/base\n" + " homebrew:\n" + " required: true\n" + " tap_repository: codeforester/homebrew-base\n" + " formula_path: /tmp/base.rb\n" + " package: codeforester/base/base" + ), + "homebrew_invalid_package": ( + "release:\n" + " github:\n" + " repository: codeforester/base\n" + " homebrew:\n" + " required: true\n" + " tap_repository: codeforester/homebrew-base\n" + " formula_path: Formula/base.rb\n" + " package: base" + ), + } + for name, release_yaml in invalid_values.items(): + with self.subTest(name=name): + with tempfile.TemporaryDirectory() as tmpdir: + manifest_path = Path(tmpdir) / "base_manifest.yaml" + manifest_path.write_text( + "\n".join( + [ + "project:", + " name: demo", + release_yaml, + "artifacts: []", + ] + ), + encoding="utf-8", + ) + + with self.assertRaises(ManifestError): + read_manifest(manifest_path) + + def test_rejects_invalid_manifest_activation_sources(self) -> None: invalid_values = { diff --git a/docs/architecture.md b/docs/architecture.md index 30cb742..4db630c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -107,6 +107,7 @@ layers. This keeps the product name and the control-plane action separate: - `basectl test` - `basectl build` - `basectl demo` +- `basectl release` - `basectl logs` Shebang-based Bash scripts can also use: diff --git a/docs/execution-model.md b/docs/execution-model.md index f876904..bea1f97 100644 --- a/docs/execution-model.md +++ b/docs/execution-model.md @@ -170,6 +170,7 @@ list is `basectl --help`; this list summarizes the shipped public surface: - `run` - `demo` - `repo` +- `release` - `logs` - `workspace` - `onboard` diff --git a/docs/release-process.md b/docs/release-process.md index de0424f..52f0996 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -31,6 +31,30 @@ documentation, and maintenance PRs leave it unchanged. A release-prep PR updates Keep upcoming changes under the `Unreleased` section in `CHANGELOG.md` until a release-prep PR moves them into a dated release section. +## Release Assistant + +Base-managed repositories can declare a `release:` section in +`base_manifest.yaml` with the version file, changelog, tag prefix, GitHub +repository, GitHub Release title, and optional Homebrew handoff metadata. + +The first `basectl release` implementation is read-only: + +```bash +basectl release check --version X.Y.Z +basectl release plan --version X.Y.Z +basectl release notes --version X.Y.Z +``` + +Use `check` before publishing to validate the version file, changelog section, +Git worktree cleanliness, current branch, GitHub CLI authentication, and local +and remote tag availability. Use `plan` to print the GitHub release target and +downstream handoff requirements. Use `notes` to print the changelog body +intended for the GitHub Release. + +This command does not create tags, publish GitHub Releases, or update the +Homebrew tap yet. The checklist below remains authoritative for those mutation +steps until guarded publish behavior is implemented. + ## Base Release Checklist Complete these steps in `codeforester/base`: diff --git a/docs/superpowers/plans/2026-06-09-release-assistant-foundation.md b/docs/superpowers/plans/2026-06-09-release-assistant-foundation.md new file mode 100644 index 0000000..22a0af1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-release-assistant-foundation.md @@ -0,0 +1,177 @@ +# Release Assistant Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the safe foundation for `basectl release`: typed manifest release metadata plus read-only `check`, `plan`, and `notes` commands. + +**Architecture:** Extend `base_setup.manifest` with an optional `release:` section and keep release behavior in a focused `base_release` Python package. Add a thin Bash `basectl release` launcher that delegates to `base-wrapper --project base base_release`, mirroring other Python-backed subcommands. Keep publishing out of this slice. + +**Tech Stack:** Bash subcommand dispatch, Python standard library, `base_cli.App`, `base_setup.manifest`, BATS, unittest. + +--- + +### Task 1: Manifest Release Metadata + +**Files:** +- Modify: `cli/python/base_setup/manifest.py` +- Modify: `cli/python/base_setup/tests/test_manifest.py` + +- [ ] **Step 1: Write failing manifest tests** + +Add tests that read a manifest containing: + +```yaml +release: + version_file: VERSION + changelog: CHANGELOG.md + tag_prefix: v + github: + repository: codeforester/base + release_title: "Base v{version}" + homebrew: + required: true + tap_repository: codeforester/homebrew-base + formula_path: Formula/base.rb + package: codeforester/base/base +``` + +Assert `manifest.release.github.repository == "codeforester/base"` and `manifest.release.homebrew.package == "codeforester/base/base"`. Add invalid-shape cases for non-mapping `release`, missing `github.repository`, invalid owner/name strings, absolute paths, and `homebrew.required: true` without tap fields. + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +BASE_HOME="$PWD" PYTHONPATH="$PWD/lib/python:$PWD/cli/python" python -m pytest cli/python/base_setup/tests/test_manifest.py -q +``` + +Expected: failures because `release` is an unsupported top-level key or `BaseManifest` has no `release` attribute. + +- [ ] **Step 3: Implement manifest dataclasses and parser** + +Add `ReleaseGithubConfig`, `ReleaseHomebrewConfig`, and `ReleaseConfig` dataclasses. Add `"release"` to allowed top-level keys. Implement `_read_release`, `_read_release_github`, `_read_release_homebrew`, path validation, repository identifier validation, and package validation. Add `release: ReleaseConfig | None` to `BaseManifest`. + +- [ ] **Step 4: Verify GREEN** + +Run the manifest tests again. Expected: all manifest tests pass. + +### Task 2: Python Release Engine + +**Files:** +- Create: `cli/python/base_release/__init__.py` +- Create: `cli/python/base_release/__main__.py` +- Create: `cli/python/base_release/engine.py` +- Create: `cli/python/base_release/tests/test_engine.py` + +- [ ] **Step 1: Write failing engine tests** + +Create tests for: + +- `notes --version 1.2.3 --manifest ` prints the `CHANGELOG.md` section for `## [1.2.3] - ...`. +- `plan --version 1.2.3 --manifest ` prints the tag, GitHub repo, GitHub release title, and Homebrew handoff fields when present. +- `check --version 1.2.3 --manifest ` fails when `VERSION` does not match. +- `check --version 1.2.3 --manifest ` fails when the changelog section is missing. + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +BASE_HOME="$PWD" PYTHONPATH="$PWD/lib/python:$PWD/cli/python" python -m pytest cli/python/base_release/tests/test_engine.py -q +``` + +Expected: module import failure for `base_release`. + +- [ ] **Step 3: Implement release engine** + +Implement `base_cli.App(name="base_release")` with commands parsed as positional arguments. Support `check`, `plan`, and `notes`; require `--version`; accept `--manifest`. Read `VERSION`, extract changelog notes, build release title from `{version}`, check local worktree cleanliness with `git status --porcelain`, and check local/remote tag existence. Keep all behavior read-only. + +- [ ] **Step 4: Verify GREEN** + +Run the engine tests again. Expected: all release engine tests pass. + +### Task 3: Bash Subcommand and Completions + +**Files:** +- Modify: `cli/bash/commands/basectl/basectl.sh` +- Create: `cli/bash/commands/basectl/subcommands/release.sh` +- Create: `cli/bash/commands/basectl/tests/release.bats` +- Modify: `cli/bash/commands/basectl/tests/help.bats` +- Modify: `lib/shell/completions/basectl_completion.sh` +- Modify: `lib/shell/completions/basectl_completion.zsh` + +- [ ] **Step 1: Write failing BATS tests** + +Add tests that `basectl release --help` prints usage and that `basectl release plan --version 1.2.3 --manifest ` delegates to the Python release package and includes Homebrew handoff output. + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +BASE_HOME="$PWD" bats cli/bash/commands/basectl/tests/release.bats +``` + +Expected: command is unrecognized or subcommand module is missing. + +- [ ] **Step 3: Implement Bash dispatch** + +Add `release` to help, dispatch, Bash completion, and Zsh completion. Create a thin `release.sh` that forwards to `"$BASE_HOME/bin/base-wrapper" --project base base_release "$@"`. + +- [ ] **Step 4: Verify GREEN** + +Run: + +```bash +BASE_HOME="$PWD" bats cli/bash/commands/basectl/tests/release.bats +``` + +Expected: release BATS tests pass. + +### Task 4: Docs and Base Manifest + +**Files:** +- Modify: `base_manifest.yaml` +- Modify: `README.md` +- Modify: `docs/release-process.md` + +- [ ] **Step 1: Add release metadata to Base manifest** + +Declare Base's release metadata with `VERSION`, `CHANGELOG.md`, `codeforester/base`, and `codeforester/homebrew-base`. + +- [ ] **Step 2: Document the read-only release assistant** + +Update README command list and release docs with `basectl release check`, `plan`, and `notes`. State that publishing and tap mutation are intentionally out of this first slice. + +- [ ] **Step 3: Run focused docs checks** + +Run: + +```bash +git diff --check +``` + +Expected: no whitespace errors. + +### Task 5: Final Verification + +**Files:** +- All changed files. + +- [ ] **Step 1: Run focused tests** + +```bash +BASE_HOME="$PWD" PYTHONPATH="$PWD/lib/python:$PWD/cli/python" python -m pytest cli/python/base_setup/tests/test_manifest.py cli/python/base_release/tests/test_engine.py -q +BASE_HOME="$PWD" bats cli/bash/commands/basectl/tests/release.bats +``` + +- [ ] **Step 2: Run full validation** + +```bash +env -u BASE_HOME ./bin/base-test +git diff --check +``` + +- [ ] **Step 3: Prepare PR** + +Commit the release assistant foundation and open a PR that closes #541 and #542 and notes partial coverage for #544. diff --git a/lib/shell/completions/basectl_completion.sh b/lib/shell/completions/basectl_completion.sh index 3ac2782..8aa64d3 100644 --- a/lib/shell/completions/basectl_completion.sh +++ b/lib/shell/completions/basectl_completion.sh @@ -63,7 +63,7 @@ _base_basectl_completion_project_profiles_or_options() { _base_basectl_completion() { local command cur - local commands="activate setup check test build demo run repo ci clean logs config doctor gh onboard update-profile update projects workspace version help" + local commands="activate setup check test build demo run repo ci release clean logs config doctor gh onboard update-profile update projects workspace version help" COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]:-}" @@ -151,6 +151,13 @@ _base_basectl_completion() { ;; esac ;; + release) + if ((COMP_CWORD == 2)); then + _base_basectl_completion_compgen "check plan notes" "$cur" + else + _base_basectl_completion_compgen "--version --manifest -h --help" "$cur" + fi + ;; clean) _base_basectl_completion_compgen "--older-than --keep-last --dry-run -v -h --help" "$cur" ;; diff --git a/lib/shell/completions/basectl_completion.zsh b/lib/shell/completions/basectl_completion.zsh index 88c1ed4..41ea5e4 100644 --- a/lib/shell/completions/basectl_completion.zsh +++ b/lib/shell/completions/basectl_completion.zsh @@ -23,6 +23,7 @@ _base_basectl_completion() { 'run:Run a project command' 'repo:Create, check, and configure repository baseline' 'ci:Run Base setup, checks, and diagnostics in CI' + 'release:Inspect release readiness and notes' 'clean:Remove old Base CLI runtime artifacts' 'logs:List and open recent Base CLI runtime logs' 'config:Inspect Base machine-local user config' @@ -211,6 +212,12 @@ _base_basectl_completion() { _describe -t projects 'Base project' project_names fi ;; + release) + _arguments '1:release command:(check plan notes)' \ + '--version[Release version]:version:' \ + '--manifest[Use a specific manifest]:path:_files' \ + '(-h --help)'{-h,--help}'[Show help text]' + ;; clean) _arguments '--older-than[Artifact age]:age:' \ '--keep-last[Keep newest log files per CLI log directory]:count:' \