fix(cli): harden release pipeline per PR #422 review

1. npm version check: three-state logic (exists/missing/error) to prevent
   silent skip on network failures, registry 5xx, or auth issues.

2. workflow_dispatch: checkout the specified tag and validate SHA matches,
   preventing builds from wrong ref.

3. Atomic push: use `git push --atomic` and detect unpushed tags via
   `git ls-remote` instead of `--no-merged` (catches branch-pushed-but-
   tag-failed state).
This commit is contained in:
Cheney 2026-05-12 17:15:35 +08:00
parent 8126faa452
commit 70b962a4c8
3 changed files with 129 additions and 9 deletions

View file

@ -29,6 +29,34 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Validate tag (workflow_dispatch only)
if: github.event_name == 'workflow_dispatch'
run: |
TAG="${{ github.event.inputs.tag }}"
# Verify tag exists
if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "ERROR: Tag '$TAG' does not exist in the repository" >&2
exit 1
fi
# Verify current checkout matches the tag
TAG_SHA=$(git rev-parse "refs/tags/$TAG^{commit}")
CURRENT_SHA=$(git rev-parse HEAD)
if [ "$TAG_SHA" != "$CURRENT_SHA" ]; then
echo "ERROR: Current checkout SHA does not match tag '$TAG'" >&2
echo " Tag SHA: $TAG_SHA" >&2
echo " Current SHA: $CURRENT_SHA" >&2
echo "" >&2
echo "This indicates the checkout did not switch to the specified tag." >&2
exit 1
fi
echo "✓ Tag '$TAG' validated (SHA: $TAG_SHA)"
- name: Set up Bun
uses: oven-sh/setup-bun@v2
@ -140,12 +168,33 @@ jobs:
PACKAGE_NAME="${{ needs.build-and-test.outputs.package_name }}"
VERSION="${{ needs.build-and-test.outputs.version }}"
if npm view "${PACKAGE_NAME}@${VERSION}" version --registry "$NPM_REGISTRY" 2>&1 | grep -Eiq '(E404|404 Not Found|is not in this registry|Not found)'; then
# Three-state check: success (exists) / 404 (missing) / error (fail job)
set +e
NPM_OUTPUT=$(npm view "${PACKAGE_NAME}@${VERSION}" version --registry "$NPM_REGISTRY" 2>&1)
NPM_EXIT_CODE=$?
set -e
if [ $NPM_EXIT_CODE -eq 0 ]; then
# Success: version exists on registry
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Version $VERSION already exists on registry, skipping publish"
elif echo "$NPM_OUTPUT" | grep -Eiq '(E404|404 Not Found|is not in this registry|Not found)'; then
# Explicit 404: version does not exist
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "Version $VERSION does not exist on registry, proceeding with publish"
else
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Version $VERSION already exists on registry, skipping publish"
# Uncertain state: network error, auth failure, registry error, etc.
echo "ERROR: Failed to check npm registry (exit code: $NPM_EXIT_CODE)" >&2
echo "Output: $NPM_OUTPUT" >&2
echo "" >&2
echo "This could be due to:" >&2
echo " - Network connectivity issues" >&2
echo " - Registry service errors (5xx)" >&2
echo " - Authentication/authorization failures" >&2
echo " - DNS or TLS problems" >&2
echo "" >&2
echo "Cannot safely determine if version exists. Failing job to prevent silent skip." >&2
exit 1
fi
- name: Configure npm authentication

View file

@ -56,7 +56,18 @@ 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)"
UNPUSHED_RELEASE_TAGS="$(git -C "$REPO_ROOT" tag --list 'cli-v*' --no-merged origin/"$CURRENT_BRANCH" 2>/dev/null || true)"
# 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
@ -152,8 +163,8 @@ git -C "$REPO_ROOT" commit -m "chore(cli): bump version to $NEW_VERSION"
log_stage "creating tag $TAG"
git -C "$REPO_ROOT" tag "$TAG"
log_stage "pushing commit and tag to origin"
git -C "$REPO_ROOT" push origin "$CURRENT_BRANCH" "$TAG"
log_stage "pushing commit and tag to origin (atomic)"
git -C "$REPO_ROOT" push --atomic origin "$CURRENT_BRANCH" "$TAG"
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"

View file

@ -120,11 +120,13 @@ run_publish() {
local input="${3-}"
local status=0
if [[ -n "$input" ]]; then
printf '%s' "$input" | REPO_ROOT="$repo" PATH="$repo/bin:$PATH" \
printf '%s' "$input" | env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE \
REPO_ROOT="$repo" PATH="$repo/bin:$PATH" \
bash "$repo/scripts/publish-cli.sh" "$bump" \
>"$repo/stdout.log" 2>"$repo/stderr.log" || status=$?
else
REPO_ROOT="$repo" PATH="$repo/bin:$PATH" \
env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE \
REPO_ROOT="$repo" PATH="$repo/bin:$PATH" \
bash "$repo/scripts/publish-cli.sh" "$bump" \
>"$repo/stdout.log" 2>"$repo/stderr.log" || status=$?
fi
@ -272,7 +274,7 @@ grep -F "release triggered" "$REPO8/stdout.log" >/dev/null
# ----------------------------------------------------------------------------
# Test 9: push failure → script exits non-zero (commit + tag stay local)
#
# Break origin to force `git push origin main cli-vX.Y.Z` to fail.
# Break origin to force `git push --atomic origin main cli-vX.Y.Z` to fail.
# ----------------------------------------------------------------------------
echo "[test] push failure surfaces error"
REPO9="$(new_tmp)"
@ -286,4 +288,62 @@ git -C "$REPO9" rev-parse "cli-v0.6.1" >/dev/null \
git -C "$REPO9" log --oneline | grep -F "chore(cli): bump version to 0.6.1" >/dev/null \
|| fail "local bump commit missing after push failure"
# ----------------------------------------------------------------------------
# Test 10: unpushed detection catches "branch pushed, tag not pushed" state
#
# Simulate: commit is on origin/main, local tag exists but was never pushed.
# The old `--no-merged` approach would miss this because the tagged commit is
# already reachable from origin/main. The new ls-remote approach catches it.
# ----------------------------------------------------------------------------
echo "[test] unpushed detection catches tag-only failure"
REPO10="$(new_tmp)"
init_repo "$REPO10" "0.7.0"
# Manually create a release commit and push only the branch (not the tag)
cd "$REPO10/cli"
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
pkg.version = '0.7.1';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
"
cd "$REPO10"
git -C "$REPO10" add cli/package.json
git -C "$REPO10" commit -q -m "chore(cli): bump version to 0.7.1"
git -C "$REPO10" tag "cli-v0.7.1"
git -C "$REPO10" push -q origin main
# Tag NOT pushed — simulates atomic push partial failure recovery
# (or a scenario where user manually pushed branch but tag failed)
status="$(run_publish "$REPO10" "patch")"
[[ "$status" -ne 0 ]] || fail "expected non-zero exit when unpushed tag detected"
grep -F "Unpushed tags" "$REPO10/stderr.log" >/dev/null \
|| { cat "$REPO10/stderr.log" >&2; fail "expected unpushed tag warning"; }
grep -F "cli-v0.7.1" "$REPO10/stderr.log" >/dev/null \
|| fail "expected cli-v0.7.1 in unpushed tag warning"
# ----------------------------------------------------------------------------
# Test 11: --atomic flag is actually passed to git push
#
# Use a git wrapper to capture the push command and verify --atomic is present.
# ----------------------------------------------------------------------------
echo "[test] push uses --atomic flag"
REPO11="$(new_tmp)"
init_repo "$REPO11" "0.8.0"
# Create a git wrapper that logs push commands
mkdir -p "$REPO11/bin-git"
cat >"$REPO11/bin-git/git" <<'WRAPPER'
#!/usr/bin/env bash
if [[ "${1:-}" == "push" ]]; then
echo "GIT_PUSH_ARGS: $*" >> "$REPO_ROOT/git-push-log.txt"
fi
exec /usr/bin/git "$@"
WRAPPER
chmod +x "$REPO11/bin-git/git"
status="$(env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE \
REPO_ROOT="$REPO11" PATH="$REPO11/bin-git:$REPO11/bin:$PATH" \
printf 'y\n' | bash "$REPO11/scripts/publish-cli.sh" "patch" \
>"$REPO11/stdout.log" 2>"$REPO11/stderr.log" && echo 0 || echo $?)"
[[ "$status" -eq 0 ]] || { cat "$REPO11/stderr.log" >&2; fail "expected success, got $status"; }
grep -F -- "--atomic" "$REPO11/git-push-log.txt" >/dev/null \
|| { cat "$REPO11/git-push-log.txt" >&2; fail "git push did not include --atomic flag"; }
echo "all tests passed"