From dda8426fb32c8bd633891782f72604828f35f250 Mon Sep 17 00:00:00 2001 From: Cheney <970320820@qq.com> Date: Fri, 15 May 2026 13:36:01 +0800 Subject: [PATCH] refactor(cli): improve publish-cli script reliability - Move version computation and pre-flight checks before build-and-test to fail fast on conflicts (existing branch/tag) instead of wasting minutes on lint/test/build - Add INT/TERM signal handlers to cleanup trap so Ctrl+C during build properly restores working tree state - Update Makefile help text to reflect PR-based workflow --- Makefile | 6 +- scripts/publish-cli.sh | 284 ++++++++++++++++++++++++++++------------- 2 files changed, 199 insertions(+), 91 deletions(-) diff --git a/Makefile b/Makefile index 2272023a..039f213d 100644 --- a/Makefile +++ b/Makefile @@ -278,13 +278,13 @@ lint-cli: ## CLI 代码检查 typecheck-cli: ## CLI 类型检查 cd cli && bun run typecheck -publish-cli: ## 发布 CLI(patch 版本)- bump + tag + push,触发 CI 自动发布 +publish-cli: ## 发布 CLI(patch 版本)- 本地 build+test → 推 release 分支 → 开 PR,合并后手动 tag 触发 CI ./scripts/publish-cli.sh patch -publish-cli-minor: ## 发布 CLI(minor 版本)- bump + tag + push,触发 CI 自动发布 +publish-cli-minor: ## 发布 CLI(minor 版本)- 本地 build+test → 推 release 分支 → 开 PR,合并后手动 tag 触发 CI ./scripts/publish-cli.sh minor -publish-cli-major: ## 发布 CLI(major 版本)- bump + tag + push,触发 CI 自动发布 +publish-cli-major: ## 发布 CLI(major 版本)- 本地 build+test → 推 release 分支 → 开 PR,合并后手动 tag 触发 CI ./scripts/publish-cli.sh major db-reset: ## 重置数据库 diff --git a/scripts/publish-cli.sh b/scripts/publish-cli.sh index 262414dd..7f8fcd2b 100755 --- a/scripts/publish-cli.sh +++ b/scripts/publish-cli.sh @@ -2,12 +2,10 @@ # Release entrypoint for the SkillHub CLI. # -# This script bumps cli/package.json, commits the bump, creates a `cli-vX.Y.Z` -# tag, and pushes it. The GitHub Actions workflow `release-cli.yml` picks up -# the tag and performs the actual build + npm publish + GitHub Release. -# -# Prefer this over direct `npm publish`: avoids local network/TLS issues with -# registry.npmjs.org, and keeps release provenance tied to CI. +# Runs local build-and-test (lint, typecheck, test, build), bumps the version +# in cli/package.json, pushes a release branch, and opens a PR to main. +# Tagging is done manually after the PR is merged — the tag push triggers +# `release-cli.yml` which builds and publishes to npm. set -euo pipefail @@ -36,6 +34,88 @@ if [[ "$BUMP_TYPE" != "patch" && "$BUMP_TYPE" != "minor" && "$BUMP_TYPE" != "maj exit 1 fi +# --- Cleanup state machine --- +# +# Tracks how far we got so the ERR trap can roll back the right pieces. +# Stages advance monotonically; the trap inspects this to decide what to undo. +# +# pre-branch — nothing created yet +# on-release — checked out release branch (may have uncommitted edits) +# committed — version bump committed locally, not yet pushed +# pushed — release branch pushed to origin (PR not yet open) +# pr-opened — PR opened (terminal success state, trap is a no-op) +# +CLEANUP_STAGE="pre-branch" +ORIGINAL_BRANCH="" +RELEASE_BRANCH="" + +cleanup_on_error() { + local exit_code=$? + trap - ERR EXIT INT TERM + set +e + + # When triggered by a signal with no failed command, $? may be 0; force a + # non-zero exit so the caller sees the interruption. + if [[ $exit_code -eq 0 ]]; then + exit_code=130 + fi + + case "$CLEANUP_STAGE" in + pre-branch) + # build-and-test runs `bun run lint/typecheck/build`, all of which + # regenerate cli/src/generated/pkg-info.ts via their pre-* hooks. If + # we got interrupted mid-flight, restore it so the working tree is + # clean for the next attempt. + git -C "$REPO_ROOT" checkout -- "$CLI_DIR/src/generated/pkg-info.ts" 2>/dev/null + ;; + pr-opened) + # Already succeeded. + ;; + on-release) + echo "" >&2 + echo "[publish-cli] error before commit — rolling back release branch" >&2 + git -C "$REPO_ROOT" checkout -- "$PACKAGE_JSON" "$CLI_DIR/src/generated/pkg-info.ts" 2>/dev/null + git -C "$REPO_ROOT" checkout "$ORIGINAL_BRANCH" 2>/dev/null + git -C "$REPO_ROOT" branch -D "$RELEASE_BRANCH" 2>/dev/null + ;; + committed) + echo "" >&2 + echo "[publish-cli] error after commit but before push — rolling back local branch" >&2 + git -C "$REPO_ROOT" checkout "$ORIGINAL_BRANCH" 2>/dev/null + git -C "$REPO_ROOT" branch -D "$RELEASE_BRANCH" 2>/dev/null + ;; + pushed) + echo "" >&2 + echo "ERROR: release branch '$RELEASE_BRANCH' was pushed but PR creation failed." >&2 + echo "" >&2 + echo "To recover:" >&2 + echo "" >&2 + echo " 1. Open the PR manually:" >&2 + echo " gh pr create --base main --head $RELEASE_BRANCH" >&2 + echo "" >&2 + echo " 2. Or roll back:" >&2 + echo " git checkout $ORIGINAL_BRANCH" >&2 + echo " git branch -D $RELEASE_BRANCH" >&2 + echo " git push origin --delete $RELEASE_BRANCH" >&2 + echo "" >&2 + ;; + esac + + exit "$exit_code" +} + +trap cleanup_on_error ERR INT TERM + +if ! command -v gh >/dev/null 2>&1; then + echo "gh CLI is required (https://cli.github.com)" >&2 + exit 1 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "gh CLI is not authenticated. Run: gh auth login" >&2 + exit 1 +fi + log_stage "checking git working tree" if [[ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]]; then echo "git working tree is not clean — commit or stash changes first" >&2 @@ -47,6 +127,7 @@ if [[ "$CURRENT_BRANCH" != "main" ]]; then echo "releases must be cut from 'main' (current: '$CURRENT_BRANCH')" >&2 exit 1 fi +ORIGINAL_BRANCH="$CURRENT_BRANCH" log_stage "pulling latest from origin/$CURRENT_BRANCH" git -C "$REPO_ROOT" pull --ff-only origin "$CURRENT_BRANCH" @@ -54,117 +135,144 @@ git -C "$REPO_ROOT" pull --ff-only origin "$CURRENT_BRANCH" log_stage "fetching tags from origin" git -C "$REPO_ROOT" fetch --tags --prune origin -log_stage "checking for unpushed release artifacts" -UNPUSHED_COMMITS="$(git -C "$REPO_ROOT" log --oneline origin/"$CURRENT_BRANCH"..HEAD 2>/dev/null || true)" +# --- Compute version & pre-flight checks (cheap, run before build) --- -# Detect local cli-v* tags that don't exist on origin. -# This catches both: (a) commit not pushed + tag not pushed, and -# (b) commit pushed but tag push failed (where --no-merged would miss it). -UNPUSHED_RELEASE_TAGS="" -while IFS= read -r local_tag; do - [[ -z "$local_tag" ]] && continue - if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$local_tag" >/dev/null 2>&1; then - UNPUSHED_RELEASE_TAGS="${UNPUSHED_RELEASE_TAGS:+$UNPUSHED_RELEASE_TAGS -}$local_tag" - fi -done < <(git -C "$REPO_ROOT" tag --list 'cli-v*' 2>/dev/null) - -if [[ -n "$UNPUSHED_COMMITS" ]] || [[ -n "$UNPUSHED_RELEASE_TAGS" ]]; then - echo "" >&2 - echo "ERROR: Detected unpushed release artifacts from a previous failed push:" >&2 - echo "" >&2 - - if [[ -n "$UNPUSHED_COMMITS" ]]; then - echo " Unpushed commits:" >&2 - echo "$UNPUSHED_COMMITS" | sed 's/^/ /' >&2 - echo "" >&2 - fi - - if [[ -n "$UNPUSHED_RELEASE_TAGS" ]]; then - echo " Unpushed tags:" >&2 - echo "$UNPUSHED_RELEASE_TAGS" | sed 's/^/ /' >&2 - echo "" >&2 - fi - - echo "Choose a recovery option:" >&2 - echo "" >&2 - echo " 1. Retry push (if the previous failure was temporary, e.g., network issue):" >&2 - if [[ -n "$UNPUSHED_RELEASE_TAGS" ]]; then - FIRST_TAG="$(echo "$UNPUSHED_RELEASE_TAGS" | head -n1)" - echo " git push origin $CURRENT_BRANCH $FIRST_TAG" >&2 - else - echo " git push origin $CURRENT_BRANCH" >&2 - fi - echo "" >&2 - echo " 2. Rollback and retry release (if you want to start fresh):" >&2 - if [[ -n "$UNPUSHED_RELEASE_TAGS" ]]; then - echo " git tag -d $UNPUSHED_RELEASE_TAGS" | tr '\n' ' ' | sed 's/ $/\n/' >&2 - fi - echo " git reset --hard origin/$CURRENT_BRANCH" >&2 - echo " # Then re-run: make publish-cli" >&2 - echo "" >&2 - exit 1 -fi - -log_stage "resolving baseline version from latest cli-v* tag" +log_stage "computing next version from latest cli-v* tag" LATEST_TAG="$(git -C "$REPO_ROOT" tag --list 'cli-v*' --sort=-version:refname | head -n1)" if [[ -n "$LATEST_TAG" ]]; then BASE_VERSION="${LATEST_TAG#cli-v}" - CURRENT_PKG_VERSION="$(node -p "require('$PACKAGE_JSON').version")" - if [[ "$BASE_VERSION" != "$CURRENT_PKG_VERSION" ]]; then - log_stage "syncing package.json $CURRENT_PKG_VERSION -> $BASE_VERSION (from $LATEST_TAG)" - node -e " - const fs = require('fs'); - const pkg = JSON.parse(fs.readFileSync('$PACKAGE_JSON', 'utf8')); - pkg.version = '$BASE_VERSION'; - fs.writeFileSync('$PACKAGE_JSON', JSON.stringify(pkg, null, 2) + '\n'); - " - fi + log_stage "baseline: $BASE_VERSION (from $LATEST_TAG)" else - log_stage "no cli-v* tags found, bumping from package.json" + BASE_VERSION="$(node -p "require('$PACKAGE_JSON').version")" + log_stage "no cli-v* tags found, baseline: $BASE_VERSION (from package.json)" fi -log_stage "bumping version ($BUMP_TYPE)" -NPM_VERSION_OUTPUT="$(cd "$CLI_DIR" && npm version "$BUMP_TYPE" --no-git-tag-version)" -NEW_VERSION="${NPM_VERSION_OUTPUT#v}" +NEW_VERSION="$(node -e " + const v = '$BASE_VERSION'.split('.').map(Number); + if (v.length !== 3 || v.some(Number.isNaN)) { + console.error('invalid baseline version: $BASE_VERSION'); + process.exit(1); + } + const t = '$BUMP_TYPE'; + if (t === 'patch') v[2]++; + else if (t === 'minor') { v[1]++; v[2] = 0; } + else if (t === 'major') { v[0]++; v[1] = 0; v[2] = 0; } + console.log(v.join('.')); +")" if [[ -z "$NEW_VERSION" ]]; then - echo "failed to parse version from npm output: $NPM_VERSION_OUTPUT" >&2 - git -C "$REPO_ROOT" checkout -- "$PACKAGE_JSON" + echo "failed to compute new version from baseline $BASE_VERSION" >&2 exit 1 fi TAG="cli-v${NEW_VERSION}" +RELEASE_BRANCH="release/${TAG}" if git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then echo "tag $TAG already exists locally" >&2 - git -C "$REPO_ROOT" checkout -- "$PACKAGE_JSON" exit 1 fi if git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then echo "tag $TAG already exists on origin" >&2 - git -C "$REPO_ROOT" checkout -- "$PACKAGE_JSON" exit 1 fi -log_stage "new version: $NEW_VERSION (tag: $TAG)" - -if ! confirm "Commit, tag, and push $TAG to origin?"; then - echo "release cancelled — reverting package.json" >&2 - git -C "$REPO_ROOT" checkout -- "$PACKAGE_JSON" +if git -C "$REPO_ROOT" rev-parse -q --verify "refs/heads/$RELEASE_BRANCH" >/dev/null; then + echo "branch $RELEASE_BRANCH already exists locally" >&2 exit 1 fi +if git -C "$REPO_ROOT" ls-remote --exit-code --heads origin "refs/heads/$RELEASE_BRANCH" >/dev/null 2>&1; then + echo "branch $RELEASE_BRANCH already exists on origin" >&2 + exit 1 +fi + +log_stage "target: $NEW_VERSION (branch: $RELEASE_BRANCH)" + +# --- Local build-and-test --- + +log_stage "installing dependencies" +(cd "$CLI_DIR" && bun install --frozen-lockfile) + +log_stage "running lint" +(cd "$CLI_DIR" && bun run lint) + +log_stage "running typecheck" +(cd "$CLI_DIR" && bun run typecheck) + +log_stage "running tests" +(cd "$CLI_DIR" && bun test) + +log_stage "running build" +(cd "$CLI_DIR" && bun run build) + +log_stage "build-and-test passed" + +# Reset only the codegen file that build-and-test regenerates. We rewrite +# pkg-info.ts again after the version bump, so this just keeps the working +# tree clean before branching. +git -C "$REPO_ROOT" checkout -- "$CLI_DIR/src/generated/pkg-info.ts" + +if ! confirm "Push $RELEASE_BRANCH and open PR to main?"; then + echo "release cancelled" >&2 + exit 1 +fi + +# --- Create branch, bump, push, open PR --- + +log_stage "creating release branch $RELEASE_BRANCH" +git -C "$REPO_ROOT" checkout -b "$RELEASE_BRANCH" +CLEANUP_STAGE="on-release" + +log_stage "writing version $NEW_VERSION to package.json" +node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('$PACKAGE_JSON', 'utf8')); + pkg.version = '$NEW_VERSION'; + fs.writeFileSync('$PACKAGE_JSON', JSON.stringify(pkg, null, 2) + '\n'); +" + +log_stage "regenerating pkg-info.ts with new version" +(cd "$CLI_DIR" && bun run scripts/generate-pkg-info.ts) + log_stage "committing version bump" -git -C "$REPO_ROOT" add "$PACKAGE_JSON" +git -C "$REPO_ROOT" add "$PACKAGE_JSON" "$CLI_DIR/src/generated/pkg-info.ts" git -C "$REPO_ROOT" commit -m "chore(cli): bump version to $NEW_VERSION" +CLEANUP_STAGE="committed" -log_stage "creating tag $TAG" -git -C "$REPO_ROOT" tag "$TAG" +log_stage "pushing release branch to origin" +git -C "$REPO_ROOT" push -u origin "$RELEASE_BRANCH" +CLEANUP_STAGE="pushed" -log_stage "pushing commit and tag to origin (atomic)" -git -C "$REPO_ROOT" push --atomic origin "$CURRENT_BRANCH" "$TAG" +log_stage "opening pull request" +PR_BODY="Bumps CLI version to \`$NEW_VERSION\`. -log_stage "release triggered — CI workflow will build and publish" -log_stage "watch progress at: https://github.com/iflytek/skillhub/actions/workflows/release-cli.yml" +Local build-and-test passed (lint, typecheck, test, build). + +After merging, tag and push to trigger the release: +\`\`\`bash +git pull origin main +git tag $TAG +git push origin $TAG +\`\`\` + +Watch the release: https://github.com/iflytek/skillhub/actions/workflows/release-cli.yml" + +gh pr create \ + --base main \ + --head "$RELEASE_BRANCH" \ + --title "chore(cli): release $NEW_VERSION" \ + --body "$PR_BODY" +CLEANUP_STAGE="pr-opened" + +log_stage "returning to $CURRENT_BRANCH" +git -C "$REPO_ROOT" checkout "$CURRENT_BRANCH" +git -C "$REPO_ROOT" branch -D "$RELEASE_BRANCH" + +log_stage "done — PR opened. After merge, tag manually:" +echo "" +echo " git pull origin main" +echo " git tag $TAG" +echo " git push origin $TAG" +echo ""