diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 0d3b5c04b0..05a546dc17 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -735,9 +735,15 @@ func (s *Service) publishSessionWaitingEvent(ctx context.Context, taskID, sessio "workflow_step_id": stepID, "new_state": string(models.TaskSessionStateWaitingForInput), } - // Include session metadata (e.g. plan_mode) so the frontend can react. - if session, err := s.repo.GetTaskSession(ctx, sessionID); err == nil && len(session.Metadata) > 0 { - eventData["session_metadata"] = session.Metadata + // Include agent_profile_id and session metadata so the frontend can + // identify the agent (e.g. MCP support) without waiting for SSR hydration. + if session, err := s.repo.GetTaskSession(ctx, sessionID); err == nil { + if session.AgentProfileID != "" { + eventData["agent_profile_id"] = session.AgentProfileID + } + if len(session.Metadata) > 0 { + eventData["session_metadata"] = session.Metadata + } } _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent( events.TaskSessionStateChanged, diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_test.go new file mode 100644 index 0000000000..485cc4b4f1 --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_test.go @@ -0,0 +1,139 @@ +package orchestrator + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" +) + +// mockEventBus captures published events for assertion. +type mockEventBus struct { + mu sync.Mutex + events []publishedEvent +} + +type publishedEvent struct { + Subject string + Event *bus.Event +} + +func (m *mockEventBus) Publish(_ context.Context, subject string, event *bus.Event) error { + m.mu.Lock() + defer m.mu.Unlock() + m.events = append(m.events, publishedEvent{Subject: subject, Event: event}) + return nil +} + +func (m *mockEventBus) Subscribe(_ string, _ bus.EventHandler) (bus.Subscription, error) { + return nil, nil +} +func (m *mockEventBus) QueueSubscribe(_, _ string, _ bus.EventHandler) (bus.Subscription, error) { + return nil, nil +} +func (m *mockEventBus) Request(_ context.Context, _ string, _ *bus.Event, _ time.Duration) (*bus.Event, error) { + return nil, nil +} +func (m *mockEventBus) Close() {} +func (m *mockEventBus) IsConnected() bool { return true } + +func (m *mockEventBus) published() []publishedEvent { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]publishedEvent, len(m.events)) + copy(out, m.events) + return out +} + +func TestPublishSessionWaitingEvent(t *testing.T) { + ctx := context.Background() + + t.Run("includes agent_profile_id and metadata", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + // Update session with agent profile and metadata. + session, err := repo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("failed to get session: %v", err) + } + session.AgentProfileID = "profile-auggie" + session.Metadata = map[string]any{"plan_mode": true} + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to update session: %v", err) + } + + eb := &mockEventBus{} + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.eventBus = eb + + svc.publishSessionWaitingEvent(ctx, "t1", "s1", "step1") + + published := eb.published() + if len(published) != 1 { + t.Fatalf("expected 1 published event, got %d", len(published)) + } + if published[0].Subject != events.TaskSessionStateChanged { + t.Errorf("expected subject %q, got %q", events.TaskSessionStateChanged, published[0].Subject) + } + + data, ok := published[0].Event.Data.(map[string]any) + if !ok { + t.Fatalf("expected event data to be map[string]any, got %T", published[0].Event.Data) + } + if data["task_id"] != "t1" { + t.Errorf("expected task_id %q, got %q", "t1", data["task_id"]) + } + if data["session_id"] != "s1" { + t.Errorf("expected session_id %q, got %q", "s1", data["session_id"]) + } + if data["new_state"] != string(models.TaskSessionStateWaitingForInput) { + t.Errorf("expected new_state %q, got %q", models.TaskSessionStateWaitingForInput, data["new_state"]) + } + if data["agent_profile_id"] != "profile-auggie" { + t.Errorf("expected agent_profile_id %q, got %v", "profile-auggie", data["agent_profile_id"]) + } + if data["session_metadata"] == nil { + t.Error("expected session_metadata to be set") + } + }) + + t.Run("omits agent_profile_id when empty", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + eb := &mockEventBus{} + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.eventBus = eb + + svc.publishSessionWaitingEvent(ctx, "t1", "s1", "step1") + + published := eb.published() + if len(published) != 1 { + t.Fatalf("expected 1 published event, got %d", len(published)) + } + + data := published[0].Event.Data.(map[string]any) + if _, exists := data["agent_profile_id"]; exists { + t.Errorf("expected agent_profile_id to be absent, got %v", data["agent_profile_id"]) + } + if _, exists := data["session_metadata"]; exists { + t.Errorf("expected session_metadata to be absent, got %v", data["session_metadata"]) + } + }) + + t.Run("no-op when eventBus is nil", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + // eventBus is nil by default + + // Should not panic. + svc.publishSessionWaitingEvent(ctx, "t1", "s1", "step1") + }) +} diff --git a/apps/cli/bin/cli.js b/apps/cli/bin/cli.js old mode 100644 new mode 100755 diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index 2ce11ab748..f6b75b8323 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -9,6 +9,7 @@ import type { QueuedMessage } from "@/lib/state/slices/session/types"; function buildSessionUpdate(payload: any): Record { const update: Record = {}; if (payload.new_state) update.state = payload.new_state; + if (payload.agent_profile_id) update.agent_profile_id = payload.agent_profile_id; if (payload.review_status !== undefined) update.review_status = payload.review_status; if (payload.workflow_step_id !== undefined) update.workflow_step_id = payload.workflow_step_id; if (payload.error_message !== undefined) update.error_message = payload.error_message; diff --git a/cliff.toml b/cliff.toml index 9a9ca2f1d8..324d36bee2 100644 --- a/cliff.toml +++ b/cliff.toml @@ -13,7 +13,7 @@ body = """ ## Unreleased {% endif %}\ {% for group, commits in commits | group_by(attribute="group") %} -### {{ group | upper_first }} +### {{ group | striptags | trim | upper_first }} {% for commit in commits %} - {{ commit.message }}\ {% endfor %} @@ -31,11 +31,11 @@ commit_preprocessors = [ { pattern = '\(#(\d+)\)', replace = "([#${1}](https://github.com/kdlbs/kandev/pull/${1}))" }, ] commit_parsers = [ - { message = "^feat", group = "Features" }, - { message = "^fix", group = "Bug Fixes" }, - { message = "^perf", group = "Performance" }, - { message = "^refactor", group = "Refactoring" }, - { message = "^docs", group = "Documentation" }, + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^docs", group = "Documentation" }, { message = "^release", skip = true }, { message = "^chore", skip = true }, { message = "^ci", skip = true }, diff --git a/scripts/release/release-pr.sh b/scripts/release/release-pr.sh new file mode 100755 index 0000000000..636712e00f --- /dev/null +++ b/scripts/release/release-pr.sh @@ -0,0 +1,735 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# release-pr.sh +# +# Unified release script for: +# - CLI npm release (apps/cli) +# - App runtime release (git tag that triggers GitHub Actions release workflow) +# - Both in one run +# +# Versioning model: +# - App tag format: vM.m +# - CLI npm version format: M.m.0 +# - Default bump mode: minor (major bump is explicit) +# +# High-level flow: +# 1) Validate preconditions (tools, clean tree, main branch, fetch tags) +# 2) Select release target (cli | app | both) +# 3) Auto-detect current versions/tags and compute next versions +# 4) Show a release plan and require confirmation +# 5) Execute release actions: +# - CLI: bump package version, install deps, build +# - App: generate CHANGELOG.md +# - Create release branch, commit, open PR, squash-merge +# - App: create/push release tag (after PR merge) +# - CLI: npm publish (after PR merge) +# +# Safety: +# - Use --dry-run to preview every action without mutating repo or publishing. +# - Use --yes for non-interactive confirmation. +# ----------------------------------------------------------------------------- +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CLI_PACKAGE_JSON="$ROOT_DIR/apps/cli/package.json" + +TARGET="" +DEFAULT_BUMP="minor" +AUTO_YES=0 +DRY_RUN=0 + +CLI_SELECTED=0 +APP_SELECTED=0 + +CURRENT_BRANCH="" +CURRENT_CLI_VERSION="" +CURRENT_CLI_MAJOR=0 +CURRENT_CLI_MINOR=0 +CURRENT_CLI_PATCH=0 +CURRENT_APP_TAG="" +CURRENT_APP_MAJOR=0 +CURRENT_APP_MINOR=0 + +CLI_BUMP="" +APP_BUMP="" +NEXT_CLI_VERSION="" +NEXT_APP_TAG="" +RELEASE_BRANCH="" + +CURRENT_STEP=0 +TOTAL_STEPS=0 + +# -- Formatting helpers ------------------------------------------------------- + +bold() { printf '\033[1m%s\033[0m' "$*"; } +dim() { printf '\033[2m%s\033[0m' "$*"; } +green() { printf '\033[32m%s\033[0m' "$*"; } +yellow() { printf '\033[33m%s\033[0m' "$*"; } +red() { printf '\033[31m%s\033[0m' "$*"; } +cyan() { printf '\033[36m%s\033[0m' "$*"; } + +log() { echo " $(dim ">>") $*"; } +phase() { echo; echo "$(bold "$*")"; } +log_ok() { echo " $(green "ok") $*"; } +log_skip() { echo " $(dim "skip") $*"; } + +step() { + ((CURRENT_STEP++)) + echo + echo "$(bold "[$CURRENT_STEP/$TOTAL_STEPS]") $1" +} + +# -- Core helpers -------------------------------------------------------------- + +usage() { + cat <<'EOF' +Usage: scripts/release/release-pr.sh [options] + +Options: + --target Release target. If omitted, prompt interactively. + --bump Bump mode. Default: minor. + --yes Skip confirmation prompts. + --dry-run Print actions without making changes/publishes. + --help, -h Show help. + +Versioning: + - App tags: vM.m + - CLI package version: M.m.0 + +Prerequisites: + - Run from a clean working tree on branch main. + - Required tools: git, node, npm, pnpm, git-cliff, gh. + +Examples: + # Fully interactive release wizard + scripts/release/release-pr.sh + + # Non-interactive CLI minor release + scripts/release/release-pr.sh --target cli --bump minor --yes + + # Preview both releases with major bump + scripts/release/release-pr.sh --target both --bump major --dry-run +EOF +} + +die() { + echo "$(red "Error:") $*" >&2 + exit 1 +} + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +run_cmd() { + if [[ "$DRY_RUN" -eq 1 ]]; then + echo " $(yellow "[dry-run]") $*" + return 0 + fi + "$@" +} + +confirm() { + local prompt="$1" + if [[ "$AUTO_YES" -eq 1 ]]; then + return 0 + fi + echo + local answer + read -r -p "$(bold "$prompt") [y/N]: " answer + answer="$(echo "${answer:-}" | tr '[:upper:]' '[:lower:]')" + [[ "$answer" == "y" || "$answer" == "yes" ]] +} + +validate_bump() { + case "$1" in + minor|major) ;; + *) die "Invalid bump '$1'. Use 'minor' or 'major'." ;; + esac +} + +compute_total_steps() { + TOTAL_STEPS=1 # create release PR (always) + [[ "$CLI_SELECTED" -eq 1 ]] && ((TOTAL_STEPS++)) # build CLI + [[ "$APP_SELECTED" -eq 1 ]] && ((TOTAL_STEPS++)) # create app tag + [[ "$CLI_SELECTED" -eq 1 ]] && ((TOTAL_STEPS++)) # publish CLI +} + +release_branch_name() { + if [[ "$CLI_SELECTED" -eq 1 && "$APP_SELECTED" -eq 1 ]]; then + echo "release/app-${NEXT_APP_TAG}-cli-${NEXT_CLI_VERSION}" + elif [[ "$CLI_SELECTED" -eq 1 ]]; then + echo "release/cli-${NEXT_CLI_VERSION}" + else + echo "release/app-${NEXT_APP_TAG}" + fi +} + +# -- Target selection ---------------------------------------------------------- + +parse_target() { + case "$1" in + cli) + CLI_SELECTED=1 + APP_SELECTED=0 + ;; + app) + CLI_SELECTED=0 + APP_SELECTED=1 + ;; + both) + CLI_SELECTED=1 + APP_SELECTED=1 + ;; + *) + die "Invalid target '$1'. Use cli, app, or both." + ;; + esac +} + +select_target_interactive() { + echo + echo "$(bold "What do you want to release?")" + echo " $(cyan "1)") cli $(dim "- npm publish apps/cli")" + echo " $(cyan "2)") app $(dim "- git tag for GitHub Actions release")" + echo " $(cyan "3)") both $(dim "- cli + app together")" + echo + local choice + read -r -p "$(bold "Choice") [1=cli / 2=app / 3=both]: " choice + case "$choice" in + 1|cli) parse_target "cli" ;; + 2|app) parse_target "app" ;; + 3|both) parse_target "both" ;; + *) die "Invalid choice '$choice'. Enter 1, 2, 3, cli, app, or both." ;; + esac + + local selected="" + [[ "$CLI_SELECTED" -eq 1 ]] && selected+="cli " + [[ "$APP_SELECTED" -eq 1 ]] && selected+="app " + log "Selected target: $(bold "$selected")" +} + +# -- Version parsing ----------------------------------------------------------- + +parse_cli_version() { + local version="$1" + if [[ ! "$version" =~ ^([0-9]+)\.([0-9]+)(\.([0-9]+))?$ ]]; then + die "CLI version '$version' is not valid. Expected M.m.0 format." + fi + CURRENT_CLI_MAJOR="${BASH_REMATCH[1]}" + CURRENT_CLI_MINOR="${BASH_REMATCH[2]}" + CURRENT_CLI_PATCH="${BASH_REMATCH[4]:-0}" +} + +detect_current_cli_version() { + [[ -f "$CLI_PACKAGE_JSON" ]] || die "Missing $CLI_PACKAGE_JSON" + CURRENT_CLI_VERSION="$(node -p "require('$CLI_PACKAGE_JSON').version")" + parse_cli_version "$CURRENT_CLI_VERSION" + log "Current CLI version: $(bold "$CURRENT_CLI_VERSION") $(dim "(from apps/cli/package.json)")" +} + +detect_current_app_version() { + local tags + tags="$(git -C "$ROOT_DIR" tag --list 'v*')" + + local found=0 + local best_major=0 + local best_minor=0 + local tag + + while IFS= read -r tag; do + [[ -z "$tag" ]] && continue + if [[ "$tag" =~ ^v([0-9]+)\.([0-9]+)(\.[0-9]+)?$ ]]; then + local major="${BASH_REMATCH[1]}" + local minor="${BASH_REMATCH[2]}" + if [[ "$found" -eq 0 ]] || (( 10#$major > 10#$best_major )) || \ + (( 10#$major == 10#$best_major && 10#$minor > 10#$best_minor )); then + found=1 + best_major="$major" + best_minor="$minor" + fi + fi + done <<<"$tags" + + CURRENT_APP_MAJOR="$best_major" + CURRENT_APP_MINOR="$best_minor" + CURRENT_APP_TAG="v${CURRENT_APP_MAJOR}.${CURRENT_APP_MINOR}" + + if [[ "$found" -eq 1 ]]; then + log "Current app tag: $(bold "$CURRENT_APP_TAG") $(dim "(latest git tag matching vM.m)")" + else + log "Current app tag: $(dim "none found") $(dim "(will start at v0.1)")" + fi +} + +# -- Bump selection ------------------------------------------------------------ + +next_minor_pair() { + local major="$1" + local minor="$2" + echo "${major}.$((10#$minor + 1))" +} + +next_major_pair() { + local major="$1" + echo "$((10#$major + 1)).0" +} + +choose_bump() { + local subject="$1" + local current="$2" + local next_minor="$3" + local next_major="$4" + + if [[ -n "$DEFAULT_BUMP" ]]; then + validate_bump "$DEFAULT_BUMP" + fi + + if [[ "$AUTO_YES" -eq 1 && -n "$DEFAULT_BUMP" ]]; then + echo "$DEFAULT_BUMP" + return + fi + + if [[ -n "$DEFAULT_BUMP" && "$TARGET" != "" ]]; then + echo "$DEFAULT_BUMP" + return + fi + + echo >&2 + echo "$(bold "$subject version bump") $(dim "(current: $current)")" >&2 + echo " $(cyan "1)") minor -> $(bold "$next_minor") $(dim "(default)")" >&2 + echo " $(cyan "2)") major -> $(bold "$next_major")" >&2 + local choice + read -r -p "$(bold "Bump") [1=minor / 2=major]: " choice + case "${choice:-1}" in + 1|minor) echo "minor" ;; + 2|major) echo "major" ;; + *) die "Invalid bump choice '$choice'. Enter 1, 2, minor, or major." ;; + esac +} + +# -- Version computation ------------------------------------------------------- + +compute_next_versions() { + if [[ "$CLI_SELECTED" -eq 1 ]]; then + local cli_minor_pair cli_major_pair + cli_minor_pair="$(next_minor_pair "$CURRENT_CLI_MAJOR" "$CURRENT_CLI_MINOR")" + cli_major_pair="$(next_major_pair "$CURRENT_CLI_MAJOR")" + + CLI_BUMP="$(choose_bump "CLI" "${CURRENT_CLI_MAJOR}.${CURRENT_CLI_MINOR}.0" \ + "${cli_minor_pair}.0" "${cli_major_pair}.0")" + validate_bump "$CLI_BUMP" + + if [[ "$CLI_BUMP" == "minor" ]]; then + NEXT_CLI_VERSION="${cli_minor_pair}.0" + else + NEXT_CLI_VERSION="${cli_major_pair}.0" + fi + log "CLI bump: $CLI_BUMP -> $(bold "$NEXT_CLI_VERSION")" + fi + + if [[ "$APP_SELECTED" -eq 1 ]]; then + local app_minor_pair app_major_pair + app_minor_pair="$(next_minor_pair "$CURRENT_APP_MAJOR" "$CURRENT_APP_MINOR")" + app_major_pair="$(next_major_pair "$CURRENT_APP_MAJOR")" + + APP_BUMP="$(choose_bump "App" "$CURRENT_APP_TAG" "v${app_minor_pair}" "v${app_major_pair}")" + validate_bump "$APP_BUMP" + + if [[ "$APP_BUMP" == "minor" ]]; then + NEXT_APP_TAG="v${app_minor_pair}" + else + NEXT_APP_TAG="v${app_major_pair}" + fi + log "App bump: $APP_BUMP -> $(bold "$NEXT_APP_TAG")" + fi +} + +# -- Preconditions ------------------------------------------------------------- + +ensure_prerequisites() { + phase "Checking prerequisites" + + for tool in git node npm pnpm git-cliff gh; do + if command_exists "$tool"; then + log_ok "$tool" + else + die "$tool is required but not found in PATH." + fi + done + + CURRENT_BRANCH="$(git -C "$ROOT_DIR" rev-parse --abbrev-ref HEAD)" + if [[ "$CURRENT_BRANCH" == "main" ]]; then + log_ok "On branch $(bold "main")" + else + die "Release must run from main branch (current: $CURRENT_BRANCH)." + fi + + if [[ -n "$(git -C "$ROOT_DIR" status --porcelain)" ]]; then + die "Working tree is not clean. Commit or stash changes before releasing." + fi + log_ok "Working tree is clean" + + log "Fetching tags from origin..." + run_cmd git -C "$ROOT_DIR" fetch --tags + log_ok "Tags fetched" +} + +ensure_npm_auth() { + [[ "$CLI_SELECTED" -eq 1 ]] || return 0 + [[ "$DRY_RUN" -eq 1 ]] && return 0 + + log "Checking npm authentication..." + local npm_user + if npm_user="$(npm whoami 2>/dev/null)"; then + log_ok "Logged in to npm as $(bold "$npm_user")" + else + echo + echo " $(yellow "npm auth required") — you are not logged in to npm." + echo " $(dim "This is needed to publish the CLI package.")" + echo + if [[ "$AUTO_YES" -eq 1 ]]; then + die "npm login required but --yes was specified. Run 'npm login' first." + fi + local answer + read -r -p "$(bold "Run npm login now?") [Y/n]: " answer + answer="$(echo "${answer:-y}" | tr '[:upper:]' '[:lower:]')" + if [[ "$answer" == "y" || "$answer" == "yes" || -z "$answer" ]]; then + npm login + # Verify login succeeded + if npm_user="$(npm whoami 2>/dev/null)"; then + log_ok "Logged in to npm as $(bold "$npm_user")" + else + die "npm login failed. Cannot publish CLI." + fi + else + die "npm login required to publish CLI. Run 'npm login' and try again." + fi + fi +} + +ensure_app_tag_available() { + [[ "$APP_SELECTED" -eq 1 ]] || return 0 + + log "Checking tag $(bold "$NEXT_APP_TAG") is available..." + if git -C "$ROOT_DIR" rev-parse "$NEXT_APP_TAG" >/dev/null 2>&1; then + die "Tag $NEXT_APP_TAG already exists locally." + fi + + if git -C "$ROOT_DIR" ls-remote --tags origin "refs/tags/$NEXT_APP_TAG" | grep -q .; then + die "Tag $NEXT_APP_TAG already exists on origin." + fi + log_ok "Tag $(bold "$NEXT_APP_TAG") is available" +} + +# -- Release plan -------------------------------------------------------------- + +print_plan() { + echo + echo "$(bold "=== Release Plan ===")" + echo " branch: $(bold "$CURRENT_BRANCH")" + if [[ "$CLI_SELECTED" -eq 1 ]]; then + echo " cli: $(bold "$CURRENT_CLI_VERSION") -> $(green "$NEXT_CLI_VERSION") ($CLI_BUMP bump)" + fi + if [[ "$APP_SELECTED" -eq 1 ]]; then + echo " app tag: $(bold "$CURRENT_APP_TAG") -> $(green "$NEXT_APP_TAG") ($APP_BUMP bump)" + fi + if [[ "$DRY_RUN" -eq 1 ]]; then + echo " mode: $(yellow "DRY RUN") $(dim "(no changes will be made)")" + fi + echo + + local n=0 + if [[ "$CLI_SELECTED" -eq 1 ]]; then + echo " $(dim "Steps for CLI:")" + echo " $(dim "1.") Bump version in apps/cli/package.json" + echo " $(dim "2.") Install dependencies (pnpm install)" + echo " $(dim "3.") Build CLI (pnpm build)" + fi + if [[ "$APP_SELECTED" -eq 1 ]]; then + echo " $(dim "Steps for App:")" + echo " $(dim "1.") Generate CHANGELOG.md (git-cliff)" + fi + echo " $(dim "Steps for Release:")" + ((n++)); echo " $(dim "$n.") Create release branch and open PR" + ((n++)); echo " $(dim "$n.") Squash-merge PR into main" + if [[ "$APP_SELECTED" -eq 1 ]]; then + ((n++)); echo " $(dim "$n.") Create and push annotated git tag $NEXT_APP_TAG" + fi + if [[ "$CLI_SELECTED" -eq 1 ]]; then + ((n++)); echo " $(dim "$n.") Publish CLI to npm" + fi + echo +} + +# -- Release actions ----------------------------------------------------------- + +commit_message() { + if [[ "$CLI_SELECTED" -eq 1 && "$APP_SELECTED" -eq 1 ]]; then + echo "release: app $NEXT_APP_TAG, cli $NEXT_CLI_VERSION" + elif [[ "$CLI_SELECTED" -eq 1 ]]; then + echo "release: cli $NEXT_CLI_VERSION" + elif [[ "$APP_SELECTED" -eq 1 ]]; then + echo "release: app $NEXT_APP_TAG" + else + echo "" + fi +} + +apply_cli_release() { + [[ "$CLI_SELECTED" -eq 1 ]] || return 0 + + step "Building CLI release" + + log "Bumping CLI version to $(bold "$NEXT_CLI_VERSION")..." + if [[ "$DRY_RUN" -eq 1 ]]; then + echo " $(yellow "[dry-run]") npm version --no-git-tag-version $NEXT_CLI_VERSION" + else + (cd "$ROOT_DIR/apps/cli" && npm version --no-git-tag-version "$NEXT_CLI_VERSION") + fi + log_ok "Version bumped in package.json" + + log "Installing dependencies..." + if [[ "$DRY_RUN" -eq 1 ]]; then + echo " $(yellow "[dry-run]") pnpm install --frozen-lockfile" + else + pnpm -C "$ROOT_DIR/apps" install --frozen-lockfile + fi + log_ok "Dependencies installed" + + log "Building CLI..." + if [[ "$DRY_RUN" -eq 1 ]]; then + echo " $(yellow "[dry-run]") pnpm -C apps/cli build" + else + pnpm -C "$ROOT_DIR/apps/cli" build + fi + log_ok "CLI built" +} + +generate_changelog() { + [[ "$APP_SELECTED" -eq 1 ]] || return 0 + + log "Generating changelog for $(bold "$NEXT_APP_TAG")..." + if [[ "$DRY_RUN" -eq 1 ]]; then + echo " $(yellow "[dry-run]") git-cliff --tag $NEXT_APP_TAG -o CHANGELOG.md" + return 0 + fi + git-cliff --tag "$NEXT_APP_TAG" -o "$ROOT_DIR/CHANGELOG.md" + log_ok "CHANGELOG.md updated" +} + +create_release_pr() { + local msg + msg="$(commit_message)" + [[ -n "$msg" ]] || return 0 + + step "Creating release PR" + + RELEASE_BRANCH="$(release_branch_name)" + + if [[ "$DRY_RUN" -eq 1 ]]; then + echo " $(yellow "[dry-run]") git checkout -b $RELEASE_BRANCH" + [[ "$CLI_SELECTED" -eq 1 ]] && echo " $(yellow "[dry-run]") git add apps/cli/package.json" + [[ "$APP_SELECTED" -eq 1 ]] && echo " $(yellow "[dry-run]") git add CHANGELOG.md" + echo " $(yellow "[dry-run]") git commit -m \"$msg\"" + echo " $(yellow "[dry-run]") git push -u origin $RELEASE_BRANCH" + echo " $(yellow "[dry-run]") gh pr create --title \"$msg\"" + echo " $(yellow "[dry-run]") gh pr merge --squash --delete-branch" + echo " $(yellow "[dry-run]") git checkout main && git pull origin main" + return 0 + fi + + log "Creating release branch $(bold "$RELEASE_BRANCH")..." + git -C "$ROOT_DIR" checkout -b "$RELEASE_BRANCH" + log_ok "Branch created" + + log "Staging release files..." + [[ "$CLI_SELECTED" -eq 1 ]] && git -C "$ROOT_DIR" add apps/cli/package.json + [[ "$APP_SELECTED" -eq 1 && -f "$ROOT_DIR/CHANGELOG.md" ]] && git -C "$ROOT_DIR" add CHANGELOG.md + + if git -C "$ROOT_DIR" diff --cached --quiet; then + log_skip "No staged changes to commit" + git -C "$ROOT_DIR" checkout main + git -C "$ROOT_DIR" branch -d "$RELEASE_BRANCH" + return 0 + fi + + log "Committing: $(dim "$msg")" + git -C "$ROOT_DIR" commit -m "$msg" + log_ok "Committed" + + log "Pushing release branch..." + git -C "$ROOT_DIR" push -u origin "$RELEASE_BRANCH" + log_ok "Branch pushed" + + local pr_body + pr_body="Automated release PR."$'\n\n'"**Changes:**" + [[ "$CLI_SELECTED" -eq 1 ]] && pr_body+=$'\n'"- CLI: \`$CURRENT_CLI_VERSION\` → \`$NEXT_CLI_VERSION\`" + [[ "$APP_SELECTED" -eq 1 ]] && pr_body+=$'\n'"- App: \`$CURRENT_APP_TAG\` → \`$NEXT_APP_TAG\`" + + log "Creating pull request..." + local pr_url + pr_url="$(gh pr create \ + --title "$msg" \ + --body "$pr_body" \ + --head "$RELEASE_BRANCH" \ + --base main)" + log_ok "PR created: $(bold "$pr_url")" + + log "Merging PR (squash)..." + if gh pr merge "$RELEASE_BRANCH" --squash --delete-branch --subject "$msg" --body ""; then + log_ok "PR merged" + else + echo + echo " $(yellow "Auto-merge failed.") Merge the PR manually:" + echo " $(cyan "$pr_url")" + echo + if [[ "$AUTO_YES" -eq 1 ]]; then + die "Cannot proceed without PR merge in --yes mode. Merge the PR manually and re-run." + fi + read -r -p "$(bold "Press Enter after merging the PR...")" + # Verify PR was merged + local pr_state + pr_state="$(gh pr view "$RELEASE_BRANCH" --json state --jq '.state')" + if [[ "$pr_state" != "MERGED" ]]; then + die "PR is not merged (state: $pr_state). Cannot continue." + fi + log_ok "PR merged" + fi + + log "Switching to main and pulling..." + git -C "$ROOT_DIR" checkout main + git -C "$ROOT_DIR" pull origin main + # Clean up local release branch if it still exists + git -C "$ROOT_DIR" branch -d "$RELEASE_BRANCH" 2>/dev/null || true + log_ok "main is up to date" +} + +create_and_push_app_tag() { + [[ "$APP_SELECTED" -eq 1 ]] || return 0 + + step "Creating app tag" + + log "Creating annotated tag $(bold "$NEXT_APP_TAG")..." + if [[ "$DRY_RUN" -eq 1 ]]; then + echo " $(yellow "[dry-run]") git tag -a $NEXT_APP_TAG -m \"release: $NEXT_APP_TAG\"" + echo " $(yellow "[dry-run]") git push origin $NEXT_APP_TAG" + return 0 + fi + + git -C "$ROOT_DIR" tag -a "$NEXT_APP_TAG" -m "release: $NEXT_APP_TAG" + log_ok "Tag created" + + log "Pushing tag to origin..." + git -C "$ROOT_DIR" push origin "$NEXT_APP_TAG" + log_ok "Tag pushed (GitHub Actions release workflow will trigger)" +} + +publish_cli() { + [[ "$CLI_SELECTED" -eq 1 ]] || return 0 + + step "Publishing CLI to npm" + + log "Running npm publish --access public..." + if [[ "$DRY_RUN" -eq 1 ]]; then + echo " $(yellow "[dry-run]") npm publish --access public" + return 0 + fi + (cd "$ROOT_DIR/apps/cli" && npm publish --access public) + log_ok "Published to npm" +} + +# -- Argument parsing ---------------------------------------------------------- + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --target) + [[ $# -ge 2 ]] || die "--target requires a value." + TARGET="$2" + shift 2 + ;; + --bump) + [[ $# -ge 2 ]] || die "--bump requires a value." + DEFAULT_BUMP="$2" + shift 2 + ;; + --yes) + AUTO_YES=1 + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + die "Unknown argument '$1'." + ;; + esac + done +} + +# -- Main ---------------------------------------------------------------------- + +main() { + parse_args "$@" + validate_bump "$DEFAULT_BUMP" + + echo + echo "$(bold "Kandev Release") $(dim "(PR mode)")" + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "$(yellow " DRY RUN MODE - no changes will be made")" + fi + + ensure_prerequisites + + phase "Selecting target and version" + + if [[ -n "$TARGET" ]]; then + parse_target "$TARGET" + local selected="" + [[ "$CLI_SELECTED" -eq 1 ]] && selected+="cli " + [[ "$APP_SELECTED" -eq 1 ]] && selected+="app " + log "Target: $(bold "$selected") $(dim "(from --target flag)")" + else + # Interactive mode should prompt for target and bump choices. + DEFAULT_BUMP="" + select_target_interactive + fi + + ensure_npm_auth + detect_current_cli_version + detect_current_app_version + compute_next_versions + ensure_app_tag_available + compute_total_steps + print_plan + + if ! confirm "Proceed with release?"; then + echo + echo "Release cancelled." + exit 0 + fi + + apply_cli_release + generate_changelog + create_release_pr + create_and_push_app_tag + publish_cli + + echo + echo "$(green "$(bold "Release complete!")")" + if [[ "$CLI_SELECTED" -eq 1 ]]; then + echo " $(green "cli:") published $NEXT_CLI_VERSION to npm" + fi + if [[ "$APP_SELECTED" -eq 1 ]]; then + echo " $(green "app:") pushed tag $NEXT_APP_TAG to origin" + fi + echo +} + +main "$@"