diff --git a/CHANGELOG.md b/CHANGELOG.md index c18db7a..cb1607a 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 guarded `basectl release publish` for annotated Git tags and GitHub + Releases, including post-publish Homebrew handoff reporting. - Added read-only `basectl release check|plan|notes` commands backed by manifest-owned release metadata. - Added opt-in project Git `origin` reachability diagnostics with diff --git a/README.md b/README.md index b80efa9..d229ad0 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ Current implemented commands include: - `basectl release check --version ` - `basectl release plan --version ` - `basectl release notes --version ` +- `basectl release publish --version ` - `basectl activate ` - `basectl test [project]` - `basectl build [target...]` @@ -569,14 +570,17 @@ Inspect release readiness for a Base-managed repository with: basectl release check --version 0.4.0 basectl release plan --version 0.4.0 basectl release notes --version 0.4.0 +basectl release publish --version 0.4.0 --dry-run +basectl release publish --version 0.4.0 --yes ``` -`basectl release` is read-only in this first slice. It validates the manifest +`basectl release check|plan|notes` are read-only. They validate 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. +CLI authentication, local and remote tag availability, and planned downstream +handoffs. `basectl release publish` reuses those checks, requires confirmation +unless `--yes` is supplied, creates an annotated tag, pushes the tag, and +creates the GitHub Release from the matching changelog section. Homebrew tap +updates remain a manual handoff printed by the command. 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; @@ -1165,7 +1169,8 @@ 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, release readiness inspection, and GitHub workflow helpers. +creation, release readiness inspection, guarded GitHub release publishing, 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/cli/bash/commands/basectl/basectl.sh b/cli/bash/commands/basectl/basectl.sh index 2238931..1d6dc18 100644 --- a/cli/bash/commands/basectl/basectl.sh +++ b/cli/bash/commands/basectl/basectl.sh @@ -27,8 +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. + release --version [options] + Inspect release readiness, plan, notes, and guarded GitHub publishing. clean [--older-than ] [--keep-last ] [options] Remove old Base CLI runtime logs, temp files, and cache entries. logs [options] diff --git a/cli/bash/commands/basectl/subcommands/release.sh b/cli/bash/commands/basectl/subcommands/release.sh index b5d1911..d2bbd85 100644 --- a/cli/bash/commands/basectl/subcommands/release.sh +++ b/cli/bash/commands/basectl/subcommands/release.sh @@ -9,14 +9,17 @@ Usage: basectl release check --version [options] basectl release plan --version [options] basectl release notes --version [options] + basectl release publish --version [options] Options: --version Release version to inspect. --manifest Use a specific base_manifest.yaml path. + --dry-run Print publish actions without creating tags or releases. + --yes Publish without an interactive confirmation prompt. -h, --help Show this help text. -Inspect release readiness, plan, and changelog notes without creating tags, -publishing GitHub Releases, or editing Homebrew taps. +Inspect release readiness, plan, changelog notes, and guarded GitHub publishing. +Homebrew tap updates remain a manual handoff. EOF } @@ -29,7 +32,7 @@ base_release_subcommand_main() { base_release_subcommand_usage return 0 ;; - check|plan|notes) + check|plan|notes|publish) ;; *) base_release_subcommand_usage >&2 diff --git a/cli/bash/commands/basectl/tests/help.bats b/cli/bash/commands/basectl/tests/help.bats index af1abd7..985cc01 100644 --- a/cli/bash/commands/basectl/tests/help.bats +++ b/cli/bash/commands/basectl/tests/help.bats @@ -15,7 +15,7 @@ load ./basectl_helpers.bash [[ "$output" == *"run [options]"* ]] [[ "$output" == *"repo [options]"* ]] [[ "$output" == *"ci [options]"* ]] - [[ "$output" == *"release --version [options]"* ]] + [[ "$output" == *"release --version [options]"* ]] [[ "$output" == *"clean [--older-than ] [--keep-last ] [options]"* ]] [[ "$output" == *"logs [options]"* ]] [[ "$output" == *"config "* ]] @@ -53,7 +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 ' 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 index da0fa1d..c5cf7dd 100644 --- a/cli/bash/commands/basectl/tests/release.bats +++ b/cli/bash/commands/basectl/tests/release.bats @@ -11,6 +11,7 @@ load ./basectl_helpers.bash [[ "$output" == *"basectl release check --version "* ]] [[ "$output" == *"basectl release plan --version "* ]] [[ "$output" == *"basectl release notes --version "* ]] + [[ "$output" == *"basectl release publish --version "* ]] } @test "basectl release delegates to the Python release layer" { @@ -35,9 +36,28 @@ EOF 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" + "$BASE_REPO_ROOT/bin/basectl" release publish --dry-run --version 1.2.3 --manifest "$manifest" [ "$status" -eq 0 ] - [ "$output" = "ARGS=plan --version 1.2.3 --manifest $manifest" ] + [ "$output" = "ARGS=publish --dry-run --version 1.2.3 --manifest $manifest" ] [ "$(cat "$TEST_TMPDIR/release-state")" = "BASE_PROJECT=base" ] } + +@test "Bash completion includes release publish commands and options" { + run env \ + BASE_HOME="$BASE_REPO_ROOT" \ + bash -c '\ + source "$BASE_HOME/lib/shell/completions/basectl_completion.sh"; \ + COMP_WORDS=(basectl release ""); \ + COMP_CWORD=2; \ + _base_basectl_completion; \ + printf "release_commands=%s\n" "${COMPREPLY[*]}"; \ + COMP_WORDS=(basectl release publish --); \ + COMP_CWORD=3; \ + _base_basectl_completion; \ + printf "release_publish_options=%s\n" "${COMPREPLY[*]}"' + + [ "$status" -eq 0 ] + [[ "$output" == *"release_commands=check plan notes publish"* ]] + [[ "$output" == *"release_publish_options=--version --manifest --dry-run --yes"* ]] +} diff --git a/cli/python/base_release/engine.py b/cli/python/base_release/engine.py index 494b4b4..8de9615 100644 --- a/cli/python/base_release/engine.py +++ b/cli/python/base_release/engine.py @@ -4,6 +4,7 @@ import shutil import subprocess import sys +import tempfile from dataclasses import dataclass from pathlib import Path @@ -31,6 +32,16 @@ class ReleaseArguments: command: str version: str manifest_path: Path | None + dry_run: bool = False + yes: bool = False + + +@dataclass +class ReleaseOptionState: + version: str | None = None + manifest_path: Path | None = None + dry_run: bool = False + yes: bool = False @dataclass(frozen=True) @@ -74,6 +85,8 @@ def run(ctx: base_cli.Context, arguments: tuple[str, ...]) -> int: return release_plan_command(release_context) if args.command == "notes": return release_notes_command(release_context) + if args.command == "publish": + return release_publish_command(release_context, args) raise ReleaseUsageError(f"Unknown release command '{args.command}'.") except ReleaseUsageError as exc: print_usage(file=sys.stderr) @@ -90,35 +103,63 @@ def parse_release_args(arguments: tuple[str, ...]) -> ReleaseArguments: raise SystemExit(0) command = arguments[0] - if command not in ("check", "plan", "notes"): + if command not in ("check", "plan", "notes", "publish"): raise ReleaseUsageError(f"Unknown release command '{command}'.") - version: str | None = None - manifest_path: Path | None = None + state = ReleaseOptionState() 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 + index = parse_release_option(command, remaining, index, state) - if version is None: + if state.version is None: raise ReleaseUsageError(f"The 'release {command}' command requires --version.") - return ReleaseArguments(command=command, version=version, manifest_path=manifest_path) + return ReleaseArguments( + command=command, + version=state.version, + manifest_path=state.manifest_path, + dry_run=state.dry_run, + yes=state.yes, + ) + + +def parse_release_option( + command: str, + arguments: list[str], + index: int, + state: ReleaseOptionState, +) -> int: + arg = arguments[index] + if arg in ("-h", "--help"): + print_usage() + raise SystemExit(0) + if arg == "--version": + state.version = read_release_option_value(arguments, index, "--version") + return index + 2 + if arg == "--manifest": + state.manifest_path = Path(read_release_option_value(arguments, index, "--manifest")).expanduser() + return index + 2 + if arg == "--dry-run": + require_publish_option(command, "--dry-run") + state.dry_run = True + return index + 1 + if arg == "--yes": + require_publish_option(command, "--yes") + state.yes = True + return index + 1 + raise ReleaseUsageError(f"Unknown release {command} option '{arg}'.") + + +def read_release_option_value(arguments: list[str], index: int, option_name: str) -> str: + value_index = index + 1 + if value_index >= len(arguments) or not arguments[value_index]: + raise ReleaseUsageError(f"Option '{option_name}' requires an argument.") + return arguments[value_index] + + +def require_publish_option(command: str, option_name: str) -> None: + if command != "publish": + raise ReleaseUsageError(f"Option '{option_name}' is only supported by release publish.") def print_usage(file=sys.stdout) -> None: @@ -127,10 +168,11 @@ def print_usage(file=sys.stdout) -> None: base_release check --version [--manifest ] base_release plan --version [--manifest ] base_release notes --version [--manifest ] + base_release publish --version [--manifest ] [--dry-run] [--yes] Purpose: - Inspect release readiness for a Base-managed project without publishing tags, - GitHub Releases, or Homebrew tap changes.""", + Inspect release readiness and guarded GitHub publishing for a Base-managed + project. Homebrew tap changes remain a manual handoff.""", file=file, ) @@ -176,18 +218,8 @@ def release_plan_command(ctx: ReleaseContext) -> int: 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") + print("") + print_homebrew_handoff(ctx, after_publish=False) return 0 @@ -196,6 +228,66 @@ def release_notes_command(ctx: ReleaseContext) -> int: return 0 +def release_publish_command(ctx: ReleaseContext, args: ReleaseArguments) -> int: + title = render_release_title(ctx) + findings = tuple(release_findings(ctx)) + blockers = tuple(finding for finding in findings if finding.status != "ok") + if not blockers: + findings = findings + (github_release_finding(ctx),) + blockers = tuple(finding for finding in findings if finding.status != "ok") + if blockers: + print(f"\nRelease publish blocked by readiness findings for {ctx.manifest.project_name} v{ctx.version}\n") + print_findings(blockers) + return 1 + + notes = extract_changelog_section(ctx.changelog, ctx.version) + + if args.dry_run: + print(f"DRY RUN: release publish for {ctx.manifest.project_name} v{ctx.version}") + print("") + print(f"Would create annotated tag: {ctx.tag_name}") + print(f"Would push tag to origin: {ctx.tag_name}") + print(f"Would create GitHub Release: {title}") + print(f"Tag URL: {github_tag_url(ctx.release.github.repository, ctx.tag_name)}") + print(f"GitHub Release URL: {github_release_url(ctx.release.github.repository, ctx.tag_name)}") + print("") + print_homebrew_handoff(ctx, after_publish=True) + return 0 + + if not args.yes: + require_interactive_publish_confirmation(ctx, title) + + project_root = ctx.manifest_path.parent + run_release_step(["git", "tag", "-a", ctx.tag_name, "-m", f"Release {ctx.tag_name}"], cwd=project_root) + run_release_step(["git", "push", "origin", ctx.tag_name], cwd=project_root) + + notes_path = write_temp_release_notes(notes) + try: + run_release_step( + [ + "gh", + "release", + "create", + ctx.tag_name, + "--repo", + ctx.release.github.repository, + "--title", + title, + "--notes-file", + str(notes_path), + ], + cwd=project_root, + ) + finally: + notes_path.unlink(missing_ok=True) + + print(f"GitHub Release published: {github_release_url(ctx.release.github.repository, ctx.tag_name)}") + print(f"Tag URL: {github_tag_url(ctx.release.github.repository, ctx.tag_name)}") + print("") + print_homebrew_handoff(ctx, after_publish=True) + return 0 + + def release_findings(ctx: ReleaseContext) -> tuple[ReleaseFinding, ...]: findings: list[ReleaseFinding] = [ ReleaseFinding("ok", "manifest", f"Release metadata found in {ctx.manifest_path}."), @@ -255,6 +347,11 @@ def local_tag_finding(root: Path, tag_name: str) -> ReleaseFinding: return ReleaseFinding("ok", "local_tag", f"Local tag {tag_name} is available.") +def print_findings(findings: tuple[ReleaseFinding, ...]) -> None: + for finding in findings: + print(f"{finding.status:<5} {finding.name:<14} {finding.message}") + + def read_version_file(path: Path) -> str | None: try: for line in path.read_text(encoding="utf-8").splitlines(): @@ -349,6 +446,71 @@ def gh_cli_finding() -> ReleaseFinding: return ReleaseFinding("error", "gh", "GitHub CLI is not authenticated for github.com.") +def github_release_finding(ctx: ReleaseContext) -> ReleaseFinding: + try: + result = subprocess.run( + ["gh", "release", "view", ctx.tag_name, "--repo", ctx.release.github.repository], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return ReleaseFinding("error", "github_release", f"Unable to inspect GitHub Release {ctx.tag_name}: {exc}.") + + if result.returncode == 0: + return ReleaseFinding("error", "github_release", f"GitHub Release {ctx.tag_name} already exists.") + + detail = result.stdout.lower() + if "release not found" in detail or "could not resolve to a release" in detail: + return ReleaseFinding("ok", "github_release", f"GitHub Release {ctx.tag_name} is available.") + + error_detail = last_non_empty_line(result.stdout) + if error_detail: + return ReleaseFinding( + "error", + "github_release", + f"Unable to inspect GitHub Release {ctx.tag_name}: {error_detail}", + ) + return ReleaseFinding("error", "github_release", f"Unable to inspect GitHub Release {ctx.tag_name}.") + + +def require_interactive_publish_confirmation(ctx: ReleaseContext, title: str) -> None: + if not sys.stdin.isatty(): + raise ReleaseError("release publish requires --yes when stdin is not interactive.") + + response = input( + f"Publish {ctx.tag_name} to {ctx.release.github.repository} with title '{title}'? [y/N] " + ) + if response.strip().lower() not in ("y", "yes"): + raise ReleaseError("release publish cancelled.") + + +def run_release_step(command: list[str], *, cwd: Path | None = None) -> None: + result = subprocess.run( + command, + cwd=cwd, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + if result.returncode != 0: + detail = last_non_empty_line(result.stdout) + joined = " ".join(command) + if detail: + raise ReleaseError(f"Release command failed: {joined}: {detail}") + raise ReleaseError(f"Release command failed: {joined}") + + +def write_temp_release_notes(notes: str) -> Path: + with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as notes_file: + notes_file.write(notes) + notes_file.write("\n") + return Path(notes_file.name) + + def local_tag_exists(root: Path, tag_name: str) -> bool: result = subprocess.run( ["git", "rev-parse", "--verify", "--quiet", f"refs/tags/{tag_name}"], @@ -402,3 +564,47 @@ def render_release_title(ctx: ReleaseContext) -> str: def github_tag_archive_url(repository: str, tag_name: str) -> str: return f"https://github.com/{repository}/archive/refs/tags/{tag_name}.tar.gz" + + +def github_release_url(repository: str, tag_name: str) -> str: + return f"https://github.com/{repository}/releases/tag/{tag_name}" + + +def github_tag_url(repository: str, tag_name: str) -> str: + return f"https://github.com/{repository}/tree/{tag_name}" + + +def print_homebrew_handoff(ctx: ReleaseContext, *, after_publish: bool) -> None: + for line in homebrew_handoff_lines(ctx, after_publish=after_publish): + print(line) + + +def homebrew_handoff_lines(ctx: ReleaseContext, *, after_publish: bool) -> tuple[str, ...]: + homebrew = ctx.release.homebrew + if homebrew is None or not homebrew.required: + return ("Homebrew handoff: not declared",) + + archive_url = github_tag_archive_url(ctx.release.github.repository, ctx.tag_name) + header = "Homebrew handoff required after GitHub release:" if after_publish else "Homebrew handoff required:" + lines = [ + header, + f" Tap repository: {homebrew.tap_repository}", + f" Formula path: {homebrew.formula_path}", + f" Package: {homebrew.package}", + f" Archive URL: {archive_url}", + f" SHA256 command: curl -fsSL {archive_url} | shasum -a 256", + " Validation commands:", + f" brew install --build-from-source {homebrew.formula_path}", + f" brew test {homebrew.package}", + f" brew audit --new --formula {homebrew.formula_path}", + " Upgrade smoke:", + " brew update", + f" brew upgrade {homebrew.package}", + ] + if requires_homebrew_upgrade_rehearsal(ctx.version): + lines.append(" 1.0 reminder: complete the Homebrew upgrade rehearsal tracked by #526.") + return tuple(lines) + + +def requires_homebrew_upgrade_rehearsal(version: str) -> bool: + return version == "1.0.0" or version.startswith("1.0.0-rc") diff --git a/cli/python/base_release/tests/test_engine.py b/cli/python/base_release/tests/test_engine.py index ddf10d1..2210a89 100644 --- a/cli/python/base_release/tests/test_engine.py +++ b/cli/python/base_release/tests/test_engine.py @@ -15,6 +15,18 @@ from base_release.engine import main +READY_FINDINGS = ( + ReleaseFinding("ok", "manifest", "Release metadata found."), + ReleaseFinding("ok", "version_file", "VERSION matches."), + ReleaseFinding("ok", "changelog", "CHANGELOG.md has a section."), + ReleaseFinding("ok", "git", "Git worktree is clean."), + ReleaseFinding("ok", "branch", "Current branch is main."), + ReleaseFinding("ok", "gh", "GitHub CLI is authenticated."), + ReleaseFinding("ok", "local_tag", "Local tag is available."), + ReleaseFinding("ok", "remote_tag", "Remote tag is available."), +) + + @contextmanager def pushd(path: Path): old_cwd = Path.cwd() @@ -149,6 +161,160 @@ def test_plan_prints_github_and_homebrew_handoff(self) -> None: "curl -fsSL https://github.com/codeforester/demo/archive/refs/tags/v1.2.3.tar.gz | shasum -a 256", stdout, ) + self.assertIn("brew install --build-from-source Formula/demo.rb", stdout) + self.assertIn("brew test codeforester/demo/demo", stdout) + self.assertIn("brew audit --new --formula Formula/demo.rb", stdout) + self.assertIn("brew upgrade codeforester/demo/demo", stdout) + + + def test_plan_prints_no_homebrew_handoff_for_github_only_project(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + manifest_path = write_release_project(root, homebrew=False) + + status, stdout, stderr = run_engine( + ["plan", "--version", "1.2.3", "--manifest", str(manifest_path)], + root, + ) + + self.assertEqual(status, 0, stderr) + self.assertIn("Homebrew handoff: not declared", stdout) + self.assertNotIn("Homebrew handoff required", stdout) + + + def test_publish_dry_run_prints_planned_actions_without_running_commands(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + manifest_path = write_release_project(root) + + with mock.patch("base_release.engine.release_findings", return_value=READY_FINDINGS), mock.patch( + "base_release.engine.github_release_finding", + return_value=ReleaseFinding("ok", "github_release", "GitHub Release is available."), + create=True, + ), mock.patch("base_release.engine.run_release_step", create=True) as run_step: + status, stdout, stderr = run_engine( + ["publish", "--dry-run", "--version", "1.2.3", "--manifest", str(manifest_path)], + root, + ) + + self.assertEqual(status, 0, stderr) + self.assertIn("DRY RUN", stdout) + self.assertIn("Would create annotated tag: v1.2.3", stdout) + self.assertIn("Would push tag to origin: v1.2.3", stdout) + self.assertIn("Would create GitHub Release: Demo v1.2.3", stdout) + self.assertIn("Homebrew handoff required after GitHub release", stdout) + run_step.assert_not_called() + + + def test_publish_requires_yes_when_stdin_is_not_interactive(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + manifest_path = write_release_project(root) + + with mock.patch("base_release.engine.release_findings", return_value=READY_FINDINGS), mock.patch( + "base_release.engine.github_release_finding", + return_value=ReleaseFinding("ok", "github_release", "GitHub Release is available."), + create=True, + ): + status, stdout, stderr = run_engine( + ["publish", "--version", "1.2.3", "--manifest", str(manifest_path)], + root, + ) + + self.assertEqual(status, 1) + self.assertEqual(stdout, "") + self.assertIn("release publish requires --yes when stdin is not interactive", stderr) + + + def test_publish_yes_creates_annotated_tag_pushes_and_creates_github_release(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + manifest_path = write_release_project(root) + commands: list[tuple[list[str], Path | None]] = [] + + def fake_run_release_step(command: list[str], *, cwd: Path | None = None) -> None: + if command[:3] == ["gh", "release", "create"]: + notes_path = Path(command[-1]) + self.assertIn("Added the release assistant.", notes_path.read_text(encoding="utf-8")) + commands.append((command, cwd)) + + with mock.patch("base_release.engine.release_findings", return_value=READY_FINDINGS), mock.patch( + "base_release.engine.github_release_finding", + return_value=ReleaseFinding("ok", "github_release", "GitHub Release is available."), + create=True, + ), mock.patch( + "base_release.engine.run_release_step", + side_effect=fake_run_release_step, + create=True, + ): + status, stdout, stderr = run_engine( + ["publish", "--yes", "--version", "1.2.3", "--manifest", str(manifest_path)], + root, + ) + + self.assertEqual(status, 0, stderr) + self.assertEqual(commands[0][0], ["git", "tag", "-a", "v1.2.3", "-m", "Release v1.2.3"]) + self.assertEqual(commands[0][1], root.resolve()) + self.assertEqual(commands[1][0], ["git", "push", "origin", "v1.2.3"]) + self.assertEqual(commands[1][1], root.resolve()) + self.assertEqual( + commands[2][0][:7], + ["gh", "release", "create", "v1.2.3", "--repo", "codeforester/demo", "--title"], + ) + self.assertEqual( + commands[2][1], + root.resolve(), + ) + self.assertIn("GitHub Release published: https://github.com/codeforester/demo/releases/tag/v1.2.3", stdout) + self.assertIn("Tag URL: https://github.com/codeforester/demo/tree/v1.2.3", stdout) + self.assertIn("Homebrew handoff required after GitHub release", stdout) + + + def test_publish_fails_when_readiness_has_errors(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + manifest_path = write_release_project(root) + + with mock.patch( + "base_release.engine.release_findings", + return_value=(ReleaseFinding("error", "git", "Git worktree has tracked or untracked changes."),), + ), mock.patch("base_release.engine.run_release_step", create=True) as run_step: + status, stdout, stderr = run_engine( + ["publish", "--yes", "--version", "1.2.3", "--manifest", str(manifest_path)], + root, + ) + + self.assertEqual(status, 1) + self.assertIn("Release publish blocked by readiness findings", stdout) + self.assertIn("error git", stdout) + self.assertEqual(stderr, "") + run_step.assert_not_called() + + + def test_publish_fails_when_github_release_already_exists(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + manifest_path = write_release_project(root) + + with mock.patch("base_release.engine.release_findings", return_value=READY_FINDINGS), mock.patch( + "base_release.engine.github_release_finding", + return_value=ReleaseFinding( + "error", + "github_release", + "GitHub Release v1.2.3 already exists.", + ), + create=True, + ), mock.patch("base_release.engine.run_release_step", create=True) as run_step: + status, stdout, stderr = run_engine( + ["publish", "--yes", "--version", "1.2.3", "--manifest", str(manifest_path)], + root, + ) + + self.assertEqual(status, 1) + self.assertIn("Release publish blocked by readiness findings", stdout) + self.assertIn("GitHub Release v1.2.3 already exists.", stdout) + self.assertEqual(stderr, "") + run_step.assert_not_called() def test_check_fails_when_version_file_does_not_match(self) -> None: diff --git a/docs/release-process.md b/docs/release-process.md index 52f0996..469a923 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -37,7 +37,7 @@ 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: +The inspection commands are read-only: ```bash basectl release check --version X.Y.Z @@ -51,9 +51,18 @@ 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. +Publishing is guarded: + +```bash +basectl release publish --version X.Y.Z --dry-run +basectl release publish --version X.Y.Z +basectl release publish --version X.Y.Z --yes +``` + +`publish` reuses the release checks, refuses existing tags or GitHub Releases, +creates an annotated tag, pushes the tag, and creates the GitHub Release from +the changelog section. It does not update the Homebrew tap; it prints the tap +handoff checklist when `release.homebrew` is declared. ## Base Release Checklist @@ -76,14 +85,20 @@ Complete these steps in `codeforester/base`: 5. Merge the release-prep PR into `master`. 6. Sync local `master`. -7. Create an annotated tag from the merged release commit: +7. Dry-run the guarded publish command: ```bash - git tag -a vX.Y.Z -m "Release vX.Y.Z" - git push origin vX.Y.Z + basectl release publish --version X.Y.Z --dry-run ``` -8. Publish the GitHub Release from the corresponding changelog section. +8. Publish the GitHub-side release artifacts: + + ```bash + basectl release publish --version X.Y.Z + ``` + + Use `--yes` only when running from a trusted non-interactive release shell. + 9. Confirm the release tag and GitHub Release are visible on GitHub. ## Homebrew Tap Checklist diff --git a/docs/superpowers/plans/2026-06-10-release-publish.md b/docs/superpowers/plans/2026-06-10-release-publish.md new file mode 100644 index 0000000..2da277e --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-release-publish.md @@ -0,0 +1,93 @@ +# Guarded Release Publish 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 guarded `basectl release publish` and complete Homebrew handoff reporting. + +**Architecture:** Keep release behavior in `cli/python/base_release/engine.py`, extending the existing argument parser and release context. Add small helpers for publish readiness, confirmation, Git/GitHub command execution, release URLs, and Homebrew handoff rendering. Keep Bash as a thin dispatcher that recognizes `publish`. + +**Tech Stack:** Python standard library, `base_cli.App`, Git CLI, GitHub CLI, Bash subcommand dispatch, BATS, pytest/unittest, pylint. + +--- + +### Task 1: Python Publish RED Tests + +**Files:** +- Modify: `cli/python/base_release/tests/test_engine.py` + +- [ ] Add tests for `publish --dry-run --version 1.2.3 --manifest ` that stub readiness and assert no publish commands run. +- [ ] Add tests for `publish --version 1.2.3 --manifest ` without `--yes` in a non-interactive test harness. +- [ ] Add tests for `publish --version 1.2.3 --manifest --yes` that stub Git/GitHub command execution and assert annotated tag, tag push, and `gh release create` command construction. +- [ ] Add tests that readiness errors and an existing GitHub Release prevent publish. +- [ ] Add tests for GitHub-only and Homebrew-required handoff output. +- [ ] Run: + +```bash +BASE_HOME="$PWD" PYTHONPATH="$PWD/lib/python:$PWD/cli/python" /Users/rameshhp/.base.d/base/.venv/bin/python -m pytest cli/python/base_release/tests/test_engine.py -q +``` + +Expected: new tests fail because `publish`, `--dry-run`, `--yes`, and full Homebrew handoff rendering are not implemented yet. + +### Task 2: Python Publish Implementation + +**Files:** +- Modify: `cli/python/base_release/engine.py` + +- [ ] Extend `ReleaseArguments` with `dry_run` and `yes`. +- [ ] Allow `publish` as a release command. +- [ ] Add `release_publish_command`. +- [ ] Add `github_release_finding` using `gh release view`. +- [ ] Add non-interactive confirmation protection unless `--yes` or `--dry-run`. +- [ ] Add `run_release_step` for Git/GitHub commands. +- [ ] Create annotated tags, push tags, and create GitHub Releases from a temp notes file. +- [ ] Render tag and release URLs. +- [ ] Extract Homebrew handoff rendering into a reusable helper used by `plan` and `publish`. +- [ ] Run the release engine tests and pylint until green. + +### Task 3: Bash Dispatch and Completions + +**Files:** +- Modify: `cli/bash/commands/basectl/subcommands/release.sh` +- Modify: `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` + +- [ ] Add `publish` to help and subcommand validation. +- [ ] Add `--dry-run` and `--yes` to release completion options. +- [ ] Add BATS assertions for help and dispatch. +- [ ] Run: + +```bash +BASE_HOME="$PWD" bats cli/bash/commands/basectl/tests/release.bats cli/bash/commands/basectl/tests/help.bats cli/bash/commands/basectl/tests/completions.bats +``` + +Expected: BATS passes after the Bash and completion updates. + +### Task 4: Documentation + +**Files:** +- Modify: `README.md` +- Modify: `docs/release-process.md` +- Modify: `docs/architecture.md` +- Modify: `docs/execution-model.md` +- Modify: `CHANGELOG.md` + +- [ ] Document `basectl release publish --version X.Y.Z --dry-run`. +- [ ] Document `basectl release publish --version X.Y.Z --yes`. +- [ ] Clarify that publish creates the GitHub-side release artifacts only. +- [ ] Clarify that Homebrew tap updates remain manual handoff work. +- [ ] Run `git diff --check`. + +### Task 5: Final Verification and PR + +**Files:** +- All changed files. + +- [ ] Run focused Python tests. +- [ ] Run focused BATS tests. +- [ ] Smoke `basectl release publish --dry-run` against a temporary release project with a stubbed `gh`. +- [ ] Run `git diff --check`. +- [ ] Run `env -u BASE_HOME ./bin/base-test`. +- [ ] Open a PR that closes #543 and #544, and closes #540 if the child slices are complete. +- [ ] Watch GitHub Actions, merge when green, sync master, and clean up. diff --git a/docs/superpowers/specs/2026-06-10-release-publish-design.md b/docs/superpowers/specs/2026-06-10-release-publish-design.md new file mode 100644 index 0000000..787b735 --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-release-publish-design.md @@ -0,0 +1,74 @@ +# Guarded Release Publish Design + +## Goal + +Add `basectl release publish` so Base can create an annotated Git tag and GitHub +Release from manifest-owned release metadata, while preserving the existing +release ceremony and its safety checks. + +## Scope + +This slice extends the release assistant from read-only inspection to guarded +GitHub publishing: + +- `basectl release publish --version X.Y.Z --dry-run` +- `basectl release publish --version X.Y.Z` +- `basectl release publish --version X.Y.Z --yes` + +The command reuses the same manifest, version file, changelog, Git worktree, +GitHub CLI, and tag readiness checks used by `release check`. It also verifies +that a GitHub Release for the tag does not already exist before publishing. + +## Publish Flow + +`publish` resolves the release context from `base_manifest.yaml`, extracts the +matching changelog section, renders the configured GitHub release title, and +then performs these guarded steps: + +1. Refuse to continue when release readiness has errors or warnings. +2. Refuse to continue when a GitHub Release already exists for the tag. +3. In `--dry-run`, print the planned tag, push, and GitHub Release actions + without mutating the repository or network state. +4. Without `--yes`, require an interactive confirmation prompt. +5. Create an annotated tag with `git tag -a -m "Release "`. +6. Push the tag with `git push origin `. +7. Create the GitHub Release with `gh release create --repo + --title --notes-file <tempfile>`. +8. Print tag and release URLs plus any Homebrew handoff required by the + manifest. + +The command intentionally has no broad force or recovery mode. Existing tags, +existing releases, dirty worktrees, mismatched version files, and missing +changelog sections remain stop conditions. + +## Homebrew Handoff + +`release.homebrew` remains declarative metadata, not automation. Both `plan` and +successful `publish` output show the downstream tap work: + +- tap repository +- formula path +- package name +- tag archive URL +- SHA256 command +- formula validation commands +- upgrade smoke commands + +For `1.0.0` release candidates or the final `1.0.0`, the handoff also reminds +the operator to complete the Homebrew upgrade rehearsal tracked by #526. + +## Non-Goals + +- Do not update, clone, commit, or push the Homebrew tap. +- Do not create release-prep PRs. +- Do not support non-GitHub hosting in this slice. +- Do not add a force mode for replacing tags or releases. + +## Testing + +Python unit tests cover dry-run command assembly, non-interactive confirmation +guarding, successful publish with GitHub calls stubbed, readiness guard +failures, existing-release guard failures, and Homebrew handoff rendering for +GitHub-only and Homebrew-required manifests. + +BATS tests cover the Bash dispatch and help/completion surface for `publish`. diff --git a/lib/shell/completions/basectl_completion.sh b/lib/shell/completions/basectl_completion.sh index 8aa64d3..9e17e73 100644 --- a/lib/shell/completions/basectl_completion.sh +++ b/lib/shell/completions/basectl_completion.sh @@ -153,9 +153,9 @@ _base_basectl_completion() { ;; release) if ((COMP_CWORD == 2)); then - _base_basectl_completion_compgen "check plan notes" "$cur" + _base_basectl_completion_compgen "check plan notes publish" "$cur" else - _base_basectl_completion_compgen "--version --manifest -h --help" "$cur" + _base_basectl_completion_compgen "--version --manifest --dry-run --yes -h --help" "$cur" fi ;; clean) diff --git a/lib/shell/completions/basectl_completion.zsh b/lib/shell/completions/basectl_completion.zsh index 41ea5e4..0ba6620 100644 --- a/lib/shell/completions/basectl_completion.zsh +++ b/lib/shell/completions/basectl_completion.zsh @@ -23,7 +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' + 'release:Inspect release readiness, notes, and publishing' 'clean:Remove old Base CLI runtime artifacts' 'logs:List and open recent Base CLI runtime logs' 'config:Inspect Base machine-local user config' @@ -213,9 +213,11 @@ _base_basectl_completion() { fi ;; release) - _arguments '1:release command:(check plan notes)' \ + _arguments '1:release command:(check plan notes publish)' \ '--version[Release version]:version:' \ '--manifest[Use a specific manifest]:path:_files' \ + '--dry-run[Print publish actions without creating tags or releases]' \ + '--yes[Publish without an interactive confirmation prompt]' \ '(-h --help)'{-h,--help}'[Show help text]' ;; clean)