skillhub/scripts/tests/publish-cli-test.sh
Cheney 7847e0aa33 fix(cli): address PR #441 review findings from @dongmucat
- Bind release tag to origin/main: PR body, end-of-run hint, and
  cli/RELEASE.md now use `git tag $TAG origin/main` so the tag is
  always placed on the merged commit, regardless of local branch state
- Reject prerelease tags in version computation: if the latest cli-v*
  tag contains non-X.Y.Z characters (e.g., -rc.1), exit with a clear
  message instead of crashing in node parsing
- Add pr-scripts.yml workflow: runs publish-cli-test.sh on scripts/**
  changes so the release script regression suite gates PRs
- Add Test 11 covering prerelease tag rejection
2026-05-19 14:57:16 +08:00

427 lines
16 KiB
Bash
Executable file

#!/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 <repo_dir> [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|<codegen> / 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" <<EOF
{
"name": "@astron-team/skillhub",
"version": "$version",
"bin": { "skillhub": "./dist/index.js" },
"files": ["dist", "README.md", "LICENSE"],
"publishConfig": { "access": "public" }
}
EOF
cat >"$repo/cli/src/generated/pkg-info.ts" <<EOF
// Generated by scripts/generate-pkg-info.ts - do not edit by hand.
export const PKG_NAME = "@astron-team/skillhub"
export const PKG_VERSION = "$version"
EOF
# Stub `bun`: handles every subcommand the publish script invokes.
# Failure injection via BUN_FAIL_AT (substring match against full args).
cat >"$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" <<EOF
stdout.log
stderr.log
gh-stub.log
bin-git/
EOF
git init -q --bare "$origin"
git -C "$repo" init -q -b main
git -C "$repo" config user.name "Test User"
git -C "$repo" config user.email "test@example.com"
git -C "$repo" remote add origin "$origin"
git -C "$repo" add cli/package.json cli/src/generated/pkg-info.ts \
scripts/publish-cli.sh bin/bun bin/gh .gitignore
git -C "$repo" commit -q -m "init"
git -C "$repo" push -q -u origin main
local tag
for tag in "${tags[@]+"${tags[@]}"}"; do
git -C "$repo" tag "$tag"
git -C "$repo" push -q origin "$tag"
done
}
# run_publish <repo> <bump> [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 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"
echo "all tests passed"