#!/usr/bin/env bash # Integration tests for scripts/publish-cli.sh. # # The script runs local build-and-test (lint, typecheck, test, build), bumps # cli/package.json, pushes a `release/cli-vX.Y.Z` branch, and opens a PR via # `gh pr create`. These tests build a self-contained fake repo per scenario, # using a real bare repository as origin so git fetch/pull/push are actually # exercised. `bun` and `gh` are stubbed to keep the heavy steps deterministic. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" PUBLISH_SCRIPT="$REPO_ROOT/scripts/publish-cli.sh" TMP_DIRS=() cleanup() { local d for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do rm -rf "$d" done } trap cleanup EXIT new_tmp() { local d d="$(mktemp -d)" TMP_DIRS+=("$d") echo "$d" } fail() { echo "FAIL: $*" >&2 exit 1 } # init_repo [initial_pkg_version] [tag_to_seed ...] # # Builds a minimal repo with: # - cli/package.json at the requested version # - cli/src/generated/pkg-info.ts (committed, so script's checkout works) # - scripts/publish-cli.sh (the script under test) # - bin/bun stub: handles install / run lint|typecheck|build| / test # - bin/gh stub: handles `auth status` and `pr create` (logs to gh-stub.log) # - a sibling bare repo as `origin` # - HEAD on `main` with the init commit already pushed # - optional pre-seeded `cli-v*` tags (created locally AND on origin) init_repo() { local repo="$1" local version="${2:-0.1.0}" local tags=() if [[ $# -gt 2 ]]; then tags=("${@:3}") fi local origin="$repo.origin.git" TMP_DIRS+=("$origin") mkdir -p "$repo/cli/src/generated" "$repo/scripts" "$repo/bin" cp "$PUBLISH_SCRIPT" "$repo/scripts/publish-cli.sh" cat >"$repo/cli/package.json" <"$repo/cli/src/generated/pkg-info.ts" <"$repo/bin/bun" <<'BUN_EOF' #!/usr/bin/env bash set -euo pipefail if [[ -n "${BUN_FAIL_AT:-}" ]] && [[ "$*" == *"$BUN_FAIL_AT"* ]]; then echo "stub bun: forced failure at: $*" >&2 exit 1 fi case "${1:-}" in install) exit 0 ;; test) exit 0 ;; run) case "${2:-}" in lint|typecheck|build) exit 0 ;; scripts/generate-pkg-info.ts) node -e " const fs = require('fs'); const path = require('path'); const cliRoot = process.cwd(); const pkg = JSON.parse(fs.readFileSync(path.join(cliRoot, 'package.json'), 'utf8')); const out = path.join(cliRoot, 'src/generated/pkg-info.ts'); fs.mkdirSync(path.dirname(out), { recursive: true }); fs.writeFileSync(out, '// Generated by scripts/generate-pkg-info.ts - do not edit by hand.\n' + 'export const PKG_NAME = ' + JSON.stringify(pkg.name) + '\n' + 'export const PKG_VERSION = ' + JSON.stringify(pkg.version) + '\n'); " exit 0 ;; *) echo "stub bun: unsupported run target: ${2:-}" >&2 exit 1 ;; esac ;; *) echo "stub bun: unsupported subcommand: ${1:-}" >&2 exit 1 ;; esac BUN_EOF chmod +x "$repo/bin/bun" # Stub `gh`: handles `auth status` (always 0) and `pr create` (logs args). # Failure injection via GH_FAIL_AT="auth"|"pr-create". cat >"$repo/bin/gh" <<'GH_EOF' #!/usr/bin/env bash set -euo pipefail if [[ -n "${GH_FAIL_AT:-}" ]]; then if [[ "$GH_FAIL_AT" == "auth" && "${1:-}" == "auth" ]]; then exit 1 fi if [[ "$GH_FAIL_AT" == "pr-create" && "${1:-}" == "pr" && "${2:-}" == "create" ]]; then echo "stub gh: forced PR create failure" >&2 exit 1 fi fi case "${1:-}" in auth) exit 0 ;; pr) if [[ "${2:-}" == "create" ]]; then printf '%s\n' "$*" >> "${GH_LOG_FILE:-/dev/null}" exit 0 fi exit 1 ;; *) echo "stub gh: unsupported: ${1:-}" >&2 exit 1 ;; esac GH_EOF chmod +x "$repo/bin/gh" cat >"$repo/.gitignore" < [stdin] [extra_env=...] # # Writes stdout/stderr to $repo/{stdout,stderr}.log and prints exit code. # `extra_env` (e.g. "BUN_FAIL_AT=lint") is forwarded as bash env assignments. run_publish() { local repo="$1" local bump="$2" local input="${3-}" local extra="${4-}" local status=0 local cmd=(env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE REPO_ROOT="$repo" PATH="$repo/bin:$PATH" GH_LOG_FILE="$repo/gh-stub.log") if [[ -n "$extra" ]]; then # Split "K1=V1 K2=V2" into separate env entries. local kv for kv in $extra; do cmd+=("$kv") done fi cmd+=(bash "$repo/scripts/publish-cli.sh" "$bump") if [[ -n "$input" ]]; then printf '%s' "$input" | "${cmd[@]}" >"$repo/stdout.log" 2>"$repo/stderr.log" || status=$? else "${cmd[@]}" >"$repo/stdout.log" 2>"$repo/stderr.log" || status=$? fi echo "$status" } # ---------------------------------------------------------------------------- # Test 1: invalid bump type → usage error, exit non-zero # ---------------------------------------------------------------------------- echo "[test] invalid bump type" REPO1="$(new_tmp)" init_repo "$REPO1" status="$(run_publish "$REPO1" "foo")" [[ "$status" -ne 0 ]] || fail "expected non-zero exit for invalid bump type" grep -F "Usage:" "$REPO1/stderr.log" >/dev/null # ---------------------------------------------------------------------------- # Test 2: dirty working tree → abort before any side effect # ---------------------------------------------------------------------------- echo "[test] dirty working tree aborts" REPO2="$(new_tmp)" init_repo "$REPO2" echo "junk" > "$REPO2/cli/package.json" status="$(run_publish "$REPO2" "patch")" [[ "$status" -ne 0 ]] || fail "expected non-zero exit for dirty tree" grep -F "git working tree is not clean" "$REPO2/stderr.log" >/dev/null # ---------------------------------------------------------------------------- # Test 3: not on main branch → abort # ---------------------------------------------------------------------------- echo "[test] non-main branch aborts" REPO3="$(new_tmp)" init_repo "$REPO3" git -C "$REPO3" checkout -q -b feature/x status="$(run_publish "$REPO3" "patch")" [[ "$status" -ne 0 ]] || fail "expected non-zero exit when not on main" grep -F "releases must be cut from 'main'" "$REPO3/stderr.log" >/dev/null grep -F "feature/x" "$REPO3/stderr.log" >/dev/null # ---------------------------------------------------------------------------- # Test 4: gh auth fails → abort early # ---------------------------------------------------------------------------- echo "[test] gh auth failure aborts" REPO4="$(new_tmp)" init_repo "$REPO4" status="$(run_publish "$REPO4" "patch" "" "GH_FAIL_AT=auth")" [[ "$status" -ne 0 ]] || fail "expected non-zero exit when gh auth fails" grep -F "gh CLI is not authenticated" "$REPO4/stderr.log" >/dev/null # ---------------------------------------------------------------------------- # Test 5: release branch already exists → abort before build (fail-fast) # ---------------------------------------------------------------------------- echo "[test] existing release branch aborts before build" REPO5="$(new_tmp)" init_repo "$REPO5" "0.1.0" "cli-v0.1.0" # Baseline is cli-v0.1.0, patch target is cli-v0.1.1, branch=release/cli-v0.1.1. # Create that branch locally so the pre-flight check catches it. git -C "$REPO5" branch "release/cli-v0.1.1" status="$(run_publish "$REPO5" "patch")" [[ "$status" -ne 0 ]] || fail "expected non-zero exit when release branch exists" grep -F "branch release/cli-v0.1.1 already exists locally" "$REPO5/stderr.log" >/dev/null \ || { cat "$REPO5/stderr.log" >&2; fail "expected 'branch already exists locally' error"; } # ---------------------------------------------------------------------------- # Test 6: confirmation cancel → no branch, no commit, no push # ---------------------------------------------------------------------------- echo "[test] confirmation cancel leaves no side effects" REPO6="$(new_tmp)" init_repo "$REPO6" "0.1.0" "cli-v0.1.0" INITIAL_HEAD="$(git -C "$REPO6" rev-parse HEAD)" status="$(run_publish "$REPO6" "patch" $'n\n')" [[ "$status" -ne 0 ]] || fail "expected non-zero exit on cancel" grep -F "release cancelled" "$REPO6/stderr.log" >/dev/null [[ "$(git -C "$REPO6" rev-parse HEAD)" == "$INITIAL_HEAD" ]] \ || fail "HEAD advanced after cancel" [[ "$(git -C "$REPO6" rev-parse --abbrev-ref HEAD)" == "main" ]] \ || fail "not on main after cancel" if git -C "$REPO6" rev-parse -q --verify "refs/heads/release/cli-v0.1.1" >/dev/null 2>&1; then fail "release branch must not exist after cancel" fi [[ -z "$(git -C "$REPO6" status --porcelain)" ]] \ || fail "working tree not clean after cancel" # ---------------------------------------------------------------------------- # Test 7: happy path — branch pushed, PR opened, returned to main # ---------------------------------------------------------------------------- echo "[test] happy path pushes branch and opens PR" REPO7="$(new_tmp)" init_repo "$REPO7" "0.5.0" status="$(run_publish "$REPO7" "patch" $'y\n')" [[ "$status" -eq 0 ]] || { cat "$REPO7/stderr.log" >&2; fail "expected success, got $status"; } # Returned to main with branch deleted locally. [[ "$(git -C "$REPO7" rev-parse --abbrev-ref HEAD)" == "main" ]] \ || fail "not back on main after success" if git -C "$REPO7" rev-parse -q --verify "refs/heads/release/cli-v0.5.1" >/dev/null 2>&1; then fail "local release branch should be deleted after success" fi # Branch on origin with bump commit. ORIGIN7="$REPO7.origin.git" git -C "$ORIGIN7" rev-parse "refs/heads/release/cli-v0.5.1" >/dev/null \ || fail "origin missing release branch" TIP_MSG="$(git -C "$ORIGIN7" log -1 --format=%s "refs/heads/release/cli-v0.5.1")" [[ "$TIP_MSG" == "chore(cli): bump version to 0.5.1" ]] \ || fail "unexpected commit message on release branch: $TIP_MSG" # pkg-info.ts AND package.json both updated in the bump commit. git -C "$ORIGIN7" show "refs/heads/release/cli-v0.5.1:cli/package.json" \ | grep -F '"version": "0.5.1"' >/dev/null \ || fail "package.json on branch missing new version" git -C "$ORIGIN7" show "refs/heads/release/cli-v0.5.1:cli/src/generated/pkg-info.ts" \ | grep -F 'PKG_VERSION = "0.5.1"' >/dev/null \ || fail "pkg-info.ts on branch missing new version" # gh pr create was invoked with expected args. [[ -f "$REPO7/gh-stub.log" ]] || fail "gh stub log missing" grep -F "pr create" "$REPO7/gh-stub.log" >/dev/null \ || fail "gh pr create not invoked" grep -F -- "--head release/cli-v0.5.1" "$REPO7/gh-stub.log" >/dev/null \ || fail "gh pr create missing --head" grep -F -- "--base main" "$REPO7/gh-stub.log" >/dev/null \ || fail "gh pr create missing --base" # ---------------------------------------------------------------------------- # Test 8: baseline from latest cli-v* tag (not package.json) # ---------------------------------------------------------------------------- echo "[test] baseline taken from latest cli-v* tag" REPO8="$(new_tmp)" init_repo "$REPO8" "0.1.0" "cli-v0.2.0" status="$(run_publish "$REPO8" "patch" $'y\n')" [[ "$status" -eq 0 ]] || { cat "$REPO8/stderr.log" >&2; fail "expected success, got $status"; } grep -F "baseline: 0.2.0 (from origin cli-v0.2.0)" "$REPO8/stdout.log" >/dev/null \ || fail "baseline log missing — version computed from wrong source" git -C "$REPO8.origin.git" rev-parse "refs/heads/release/cli-v0.2.1" >/dev/null \ || fail "expected branch release/cli-v0.2.1 on origin" # ---------------------------------------------------------------------------- # Test 9: gh pr create fails → branch stays on origin, recovery printed # ---------------------------------------------------------------------------- echo "[test] gh pr create failure triggers pushed-stage cleanup" REPO9="$(new_tmp)" init_repo "$REPO9" "0.6.0" status="$(run_publish "$REPO9" "patch" $'y\n' "GH_FAIL_AT=pr-create")" [[ "$status" -ne 0 ]] || fail "expected non-zero exit when gh pr create fails" # Recovery instructions emitted by the trap. grep -F "release branch 'release/cli-v0.6.1' was pushed but PR creation failed" \ "$REPO9/stderr.log" >/dev/null \ || { cat "$REPO9/stderr.log" >&2; fail "missing pushed-stage recovery message"; } grep -F "git push origin --delete release/cli-v0.6.1" "$REPO9/stderr.log" >/dev/null \ || fail "missing rollback hint in recovery message" # Branch is on origin (push succeeded before gh failed). git -C "$REPO9.origin.git" rev-parse "refs/heads/release/cli-v0.6.1" >/dev/null \ || fail "expected branch on origin after push, before failed PR" # ---------------------------------------------------------------------------- # Test 10: push fails → committed-stage cleanup, branch deleted locally # ---------------------------------------------------------------------------- echo "[test] push failure rolls back local branch" REPO10="$(new_tmp)" init_repo "$REPO10" "0.7.0" mkdir -p "$REPO10/bin-git" cat >"$REPO10/bin-git/git" <<'WRAPPER' #!/usr/bin/env bash if [[ "${1:-}" == "-C" && "${3:-}" == "push" ]]; then echo "fatal: forced push failure" >&2 exit 128 fi exec /usr/bin/git "$@" WRAPPER chmod +x "$REPO10/bin-git/git" status=0 printf 'y\n' | env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE \ REPO_ROOT="$REPO10" PATH="$REPO10/bin-git:$REPO10/bin:$PATH" \ GH_LOG_FILE="$REPO10/gh-stub.log" \ bash "$REPO10/scripts/publish-cli.sh" "patch" \ >"$REPO10/stdout.log" 2>"$REPO10/stderr.log" || status=$? [[ "$status" -ne 0 ]] || fail "expected non-zero exit when push fails" grep -F "rolling back local branch" "$REPO10/stderr.log" >/dev/null \ || { cat "$REPO10/stderr.log" >&2; fail "missing committed-stage rollback message"; } # Back on main, release branch deleted. [[ "$(git -C "$REPO10" rev-parse --abbrev-ref HEAD)" == "main" ]] \ || fail "not on main after push failure cleanup" if git -C "$REPO10" rev-parse -q --verify "refs/heads/release/cli-v0.7.1" >/dev/null 2>&1; then fail "local release branch should be deleted after push failure" fi # Origin must NOT have the branch (push was blocked). if git -C "$REPO10.origin.git" rev-parse -q --verify "refs/heads/release/cli-v0.7.1" >/dev/null 2>&1; then fail "origin should not have branch when push failed" fi # gh pr create must NOT have run. if [[ -f "$REPO10/gh-stub.log" ]] && grep -F "pr create" "$REPO10/gh-stub.log" >/dev/null; then fail "gh pr create ran despite push failure" fi # ---------------------------------------------------------------------------- # Test 11: prerelease tag → abort with clear message # ---------------------------------------------------------------------------- echo "[test] prerelease tag aborts with message" REPO11="$(new_tmp)" init_repo "$REPO11" "0.1.0" "cli-v0.2.0-rc.1" status="$(run_publish "$REPO11" "patch")" [[ "$status" -ne 0 ]] || fail "expected non-zero exit for prerelease tag" grep -F "contains prerelease suffix" "$REPO11/stderr.log" >/dev/null \ || { cat "$REPO11/stderr.log" >&2; fail "expected prerelease rejection message"; } grep -F "only supports pure X.Y.Z" "$REPO11/stderr.log" >/dev/null \ || fail "expected X.Y.Z hint in error" # ---------------------------------------------------------------------------- # Test 12: local-only orphan tag must not influence baseline # ---------------------------------------------------------------------------- # Simulates the failure mode where `git push origin cli-vX.Y.Z` failed after # `git tag cli-vX.Y.Z origin/main` succeeded locally. The orphan tag exists # locally but not on origin. Baseline must come from origin only. echo "[test] local-only orphan tag ignored for baseline" REPO12="$(new_tmp)" init_repo "$REPO12" "0.1.0" "cli-v0.1.0" git -C "$REPO12" tag "cli-v0.3.0" status="$(run_publish "$REPO12" "patch" $'y\n')" [[ "$status" -eq 0 ]] || { cat "$REPO12/stderr.log" >&2; fail "expected success, got $status"; } grep -F "baseline: 0.1.0 (from origin cli-v0.1.0)" "$REPO12/stdout.log" >/dev/null \ || { cat "$REPO12/stdout.log" >&2; fail "baseline must come from origin (0.1.0), not local orphan (0.3.0)"; } git -C "$REPO12.origin.git" rev-parse "refs/heads/release/cli-v0.1.1" >/dev/null \ || fail "expected branch release/cli-v0.1.1 on origin (orphan should not have shifted target to 0.3.1)" echo "all tests passed"