refactor(cli): improve publish-cli script reliability (#441)

* 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

* fix(cli): use git checkout -f for robust cleanup

Address code review feedback from gemini-code-assist bot:

- Use `git checkout -f` in on-release and committed cleanup stages
  to ensure reliable branch switching even when files are staged
  but not committed (e.g., interrupted after `git add` but before
  `git commit`)
- Remove redundant `git checkout -- <file>` in on-release stage
  since `-f` already discards all local changes

This prevents cleanup failures when the script is interrupted
between staging and committing.

* fix(cli): address PR #441 review findings

- Fix ERR trap bypass: remove `if !` wrapper around `gh pr create` so
  set -e triggers the trap and prints pushed-stage recovery instructions
- Fix command injection: all node -e/-p calls now use process.env
  instead of interpolating shell variables into JS string literals
- Rewrite cli/RELEASE.md to document the new PR-based release flow
- Rewrite scripts/tests/publish-cli-test.sh with 10 tests covering
  the new flow (stubs for bun/gh, pre-flight checks, happy path,
  cleanup state machine stages)

* 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

* fix(cli): compute publish baseline from origin tags only

A failed `git push origin cli-vX.Y.Z` after a successful local tag
leaves an orphan tag locally. The previous `git tag --list` baseline
would then treat it as the latest release, causing skipped versions or
publishes based on an unreleased tag.

Switch to `git ls-remote --tags --refs origin 'cli-v*' | sort -V` so
the baseline reflects only what is actually on origin. Local orphan
tags can still collide with the computed target tag, which fails fast
with a clear message as before.

Adds test 12 covering the orphan-tag scenario.
This commit is contained in:
Cheney 2026-06-02 14:30:39 +08:00 committed by GitHub
parent b7b8fd3d5d
commit 0b1c366f8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 552 additions and 259 deletions

19
.github/workflows/pr-scripts.yml vendored Normal file
View file

@ -0,0 +1,19 @@
name: PR Scripts
on:
pull_request:
paths:
- 'scripts/**'
- '.github/workflows/pr-scripts.yml'
jobs:
publish-cli-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '21'
- run: bash scripts/tests/publish-cli-test.sh

View file

@ -278,13 +278,13 @@ lint-cli: ## CLI 代码检查
typecheck-cli: ## CLI 类型检查 typecheck-cli: ## CLI 类型检查
cd cli && bun run typecheck cd cli && bun run typecheck
publish-cli: ## 发布 CLIpatch 版本)- bump + tag + push触发 CI 自动发布 publish-cli: ## 发布 CLIpatch 版本)- 本地 build+test → 推 release 分支 → 开 PR合并后手动 tag 触发 CI
./scripts/publish-cli.sh patch ./scripts/publish-cli.sh patch
publish-cli-minor: ## 发布 CLIminor 版本)- bump + tag + push触发 CI 自动发布 publish-cli-minor: ## 发布 CLIminor 版本)- 本地 build+test → 推 release 分支 → 开 PR合并后手动 tag 触发 CI
./scripts/publish-cli.sh minor ./scripts/publish-cli.sh minor
publish-cli-major: ## 发布 CLImajor 版本)- bump + tag + push触发 CI 自动发布 publish-cli-major: ## 发布 CLImajor 版本)- 本地 build+test → 推 release 分支 → 开 PR合并后手动 tag 触发 CI
./scripts/publish-cli.sh major ./scripts/publish-cli.sh major
db-reset: ## 重置数据库 db-reset: ## 重置数据库

View file

@ -2,7 +2,14 @@
## Overview ## Overview
CLI releases are fully automated. Running `make publish-cli` on a clean `main` branch bumps the version, commits, creates a `cli-vX.Y.Z` tag, and pushes everything to origin. The GitHub Actions workflow [`release-cli.yml`](../.github/workflows/release-cli.yml) listens for the tag and handles build, test, npm publish, and GitHub Release creation. CLI releases use a PR-based flow. Running `make publish-cli` on a clean `main` branch:
1. Runs local build-and-test (lint, typecheck, test, build)
2. Computes the next version from the latest `cli-v*` tag on `origin`
3. Creates a `release/cli-vX.Y.Z` branch with the version bump committed
4. Pushes the branch and opens a PR to `main`
After the PR is merged, you manually tag and push — the tag triggers [`release-cli.yml`](../.github/workflows/release-cli.yml) which builds, publishes to npm, and creates a GitHub Release.
## Prerequisites ## Prerequisites
@ -21,8 +28,9 @@ Configure in GitHub repository → Settings → Secrets and variables → Action
### Local Environment ### Local Environment
- `node` and `npm` installed (the script uses `npm version` to bump) - `node` and `bun` installed
- `git` installed with push access to the repository - `gh` CLI installed and authenticated (`gh auth login`)
- `git` with push access to the repository
- On the `main` branch with a clean working tree - On the `main` branch with a clean working tree
### Package Configuration ### Package Configuration
@ -40,7 +48,7 @@ In [`cli/package.json`](./package.json):
## Release Process ## Release Process
### One-shot Release ### Step 1: Run the publish script
From the repository root, on a clean `main` branch: From the repository root, on a clean `main` branch:
@ -52,15 +60,31 @@ make publish-cli-major # major: 0.1.5 -> 1.0.0
[`scripts/publish-cli.sh`](../scripts/publish-cli.sh) performs the following steps: [`scripts/publish-cli.sh`](../scripts/publish-cli.sh) performs the following steps:
1. Verify the working tree is clean 1. Verify `gh` CLI is installed and authenticated
2. Require the current branch to be `main`, otherwise abort 2. Verify the working tree is clean and on `main`
3. `git pull --ff-only` from `origin/main` 3. `git pull --ff-only` from `origin/main`
4. Fetch remote tags and align `package.json` with the latest `cli-v*` tag 4. Run full local build-and-test (lint, typecheck, test, build)
5. Compute the new version via `npm version <bump>` 5. Compute the next version from the latest `cli-v*` tag on `origin` (via `git ls-remote`, so local orphan tags from a failed `git push origin cli-vX.Y.Z` are ignored)
6. Verify the new tag does not exist locally or on origin 6. Verify the tag and release branch don't already exist
7. After interactive confirmation: commit the bump, create the `cli-vX.Y.Z` tag, push both commit and tag to origin 7. After interactive confirmation: create release branch, commit version bump, push, and open PR
Pushing the tag triggers CI — no further manual action required. ### Step 2: Merge the PR
Review and merge the PR on GitHub as usual.
### Step 3: Tag and push
After the PR is merged:
```bash
git fetch origin main
git tag cli-vX.Y.Z origin/main # replace with the actual version
git push origin cli-vX.Y.Z
```
This ensures the tag is always placed on the merge commit on `origin/main`, regardless of your local branch state.
Pushing the tag triggers CI which builds, publishes to npm, and creates a GitHub Release.
### CI Workflow ### CI Workflow
@ -104,6 +128,22 @@ From the Actions UI:
2. Enter an existing tag name matching `cli-vX.Y.Z` 2. Enter an existing tag name matching `cli-vX.Y.Z`
3. Optionally enable skip npm publish 3. Optionally enable skip npm publish
## Error Recovery
The script uses a cleanup state machine. If it fails at different stages:
- **Before push**: release branch is deleted locally, you're returned to `main`
- **After push, before PR**: the script prints recovery instructions (open PR manually or delete the remote branch)
- **After PR opened**: success — no cleanup needed
If you need to manually clean up a failed release:
```bash
git checkout main
git branch -D release/cli-vX.Y.Z # delete local branch
git push origin --delete release/cli-vX.Y.Z # delete remote branch (if pushed)
```
## Troubleshooting ## Troubleshooting
### `releases must be cut from 'main'` ### `releases must be cut from 'main'`
@ -118,6 +158,10 @@ Commit or stash local changes first.
The previous release didn't clean up, or someone else released the same version. Check `git tag --list 'cli-v*'` and remote tags, then retry with a higher version. The previous release didn't clean up, or someone else released the same version. Check `git tag --list 'cli-v*'` and remote tags, then retry with a higher version.
### `branch release/cli-vX.Y.Z already exists`
A previous release attempt left a stale branch. Delete it locally and/or on origin, then retry.
### npm Publish Fails ### npm Publish Fails
- **403 with 2FA message**: `NPM_TOKEN` is not an Automation Token, or bypass 2FA is not enabled — regenerate with the correct type - **403 with 2FA message**: `NPM_TOKEN` is not an Automation Token, or bypass 2FA is not enabled — regenerate with the correct type

View file

@ -2,12 +2,10 @@
# Release entrypoint for the SkillHub CLI. # Release entrypoint for the SkillHub CLI.
# #
# This script bumps cli/package.json, commits the bump, creates a `cli-vX.Y.Z` # Runs local build-and-test (lint, typecheck, test, build), bumps the version
# tag, and pushes it. The GitHub Actions workflow `release-cli.yml` picks up # in cli/package.json, pushes a release branch, and opens a PR to main.
# the tag and performs the actual build + npm publish + GitHub Release. # Tagging is done manually after the PR is merged — the tag push triggers
# # `release-cli.yml` which builds and publishes to npm.
# Prefer this over direct `npm publish`: avoids local network/TLS issues with
# registry.npmjs.org, and keeps release provenance tied to CI.
set -euo pipefail set -euo pipefail
@ -36,6 +34,87 @@ if [[ "$BUMP_TYPE" != "patch" && "$BUMP_TYPE" != "minor" && "$BUMP_TYPE" != "maj
exit 1 exit 1
fi 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 -f "$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 -f "$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" log_stage "checking git working tree"
if [[ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]]; then if [[ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]]; then
echo "git working tree is not clean — commit or stash changes first" >&2 echo "git working tree is not clean — commit or stash changes first" >&2
@ -47,124 +126,166 @@ if [[ "$CURRENT_BRANCH" != "main" ]]; then
echo "releases must be cut from 'main' (current: '$CURRENT_BRANCH')" >&2 echo "releases must be cut from 'main' (current: '$CURRENT_BRANCH')" >&2
exit 1 exit 1
fi fi
ORIGINAL_BRANCH="$CURRENT_BRANCH"
log_stage "pulling latest from origin/$CURRENT_BRANCH" log_stage "pulling latest from origin/$CURRENT_BRANCH"
git -C "$REPO_ROOT" pull --ff-only origin "$CURRENT_BRANCH" git -C "$REPO_ROOT" pull --ff-only origin "$CURRENT_BRANCH"
log_stage "fetching tags from origin" # --- Compute version & pre-flight checks (cheap, run before build) ---
git -C "$REPO_ROOT" fetch --tags --prune origin #
# Baseline is read from origin via `ls-remote`, not from local tags. A local
# orphan tag left over from a failed `git push origin cli-vX.Y.Z` must not
# influence the next version — otherwise we'd skip versions or release on top
# of something that was never published.
log_stage "checking for unpushed release artifacts" log_stage "computing next version from latest origin cli-v* tag"
UNPUSHED_COMMITS="$(git -C "$REPO_ROOT" log --oneline origin/"$CURRENT_BRANCH"..HEAD 2>/dev/null || true)" LATEST_TAG="$(git -C "$REPO_ROOT" ls-remote --tags --refs origin 'cli-v*' \
| awk '{sub(/^refs\/tags\//, "", $2); print $2}' \
# Detect local cli-v* tags that don't exist on origin. | sort -V \
# This catches both: (a) commit not pushed + tag not pushed, and | tail -n1)"
# (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"
LATEST_TAG="$(git -C "$REPO_ROOT" tag --list 'cli-v*' --sort=-version:refname | head -n1)"
if [[ -n "$LATEST_TAG" ]]; then if [[ -n "$LATEST_TAG" ]]; then
BASE_VERSION="${LATEST_TAG#cli-v}" BASE_VERSION="${LATEST_TAG#cli-v}"
CURRENT_PKG_VERSION="$(node -p "require('$PACKAGE_JSON').version")" # Reject prerelease tags (e.g., cli-v0.2.0-rc.1). Only pure X.Y.Z is supported.
if [[ "$BASE_VERSION" != "$CURRENT_PKG_VERSION" ]]; then if [[ "$BASE_VERSION" =~ [^0-9.] ]]; then
log_stage "syncing package.json $CURRENT_PKG_VERSION -> $BASE_VERSION (from $LATEST_TAG)" echo "latest origin tag $LATEST_TAG contains prerelease suffix: $BASE_VERSION" >&2
node -e " echo "this script only supports pure X.Y.Z versions" >&2
const fs = require('fs'); echo "skip prerelease tags manually or use a different baseline" >&2
const pkg = JSON.parse(fs.readFileSync('$PACKAGE_JSON', 'utf8')); exit 1
pkg.version = '$BASE_VERSION';
fs.writeFileSync('$PACKAGE_JSON', JSON.stringify(pkg, null, 2) + '\n');
"
fi fi
log_stage "baseline: $BASE_VERSION (from origin $LATEST_TAG)"
else else
log_stage "no cli-v* tags found, bumping from package.json" BASE_VERSION="$(PACKAGE_JSON="$PACKAGE_JSON" node -p "require(process.env.PACKAGE_JSON).version")"
log_stage "no cli-v* tags on origin, baseline: $BASE_VERSION (from package.json)"
fi fi
log_stage "bumping version ($BUMP_TYPE)" NEW_VERSION="$(BASE_VERSION="$BASE_VERSION" BUMP_TYPE="$BUMP_TYPE" node -e "
NPM_VERSION_OUTPUT="$(cd "$CLI_DIR" && npm version "$BUMP_TYPE" --no-git-tag-version)" const v = process.env.BASE_VERSION.split('.').map(Number);
NEW_VERSION="${NPM_VERSION_OUTPUT#v}" if (v.length !== 3 || v.some(Number.isNaN)) {
console.error('invalid baseline version: ' + process.env.BASE_VERSION);
process.exit(1);
}
const t = process.env.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 if [[ -z "$NEW_VERSION" ]]; then
echo "failed to parse version from npm output: $NPM_VERSION_OUTPUT" >&2 echo "failed to compute new version from baseline $BASE_VERSION" >&2
git -C "$REPO_ROOT" checkout -- "$PACKAGE_JSON"
exit 1 exit 1
fi fi
TAG="cli-v${NEW_VERSION}" TAG="cli-v${NEW_VERSION}"
RELEASE_BRANCH="release/${TAG}"
if git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then if git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "tag $TAG already exists locally" >&2 echo "tag $TAG already exists locally" >&2
git -C "$REPO_ROOT" checkout -- "$PACKAGE_JSON"
exit 1 exit 1
fi fi
if git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then 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 echo "tag $TAG already exists on origin" >&2
git -C "$REPO_ROOT" checkout -- "$PACKAGE_JSON"
exit 1 exit 1
fi fi
log_stage "new version: $NEW_VERSION (tag: $TAG)" if git -C "$REPO_ROOT" rev-parse -q --verify "refs/heads/$RELEASE_BRANCH" >/dev/null; then
echo "branch $RELEASE_BRANCH already exists locally" >&2
if ! confirm "Commit, tag, and push $TAG to origin?"; then
echo "release cancelled — reverting package.json" >&2
git -C "$REPO_ROOT" checkout -- "$PACKAGE_JSON"
exit 1 exit 1
fi 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"
NEW_VERSION="$NEW_VERSION" PACKAGE_JSON="$PACKAGE_JSON" node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync(process.env.PACKAGE_JSON, 'utf8'));
pkg.version = process.env.NEW_VERSION;
fs.writeFileSync(process.env.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" 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" git -C "$REPO_ROOT" commit -m "chore(cli): bump version to $NEW_VERSION"
CLEANUP_STAGE="committed"
log_stage "creating tag $TAG" log_stage "pushing release branch to origin"
git -C "$REPO_ROOT" tag "$TAG" git -C "$REPO_ROOT" push -u origin "$RELEASE_BRANCH"
CLEANUP_STAGE="pushed"
log_stage "pushing commit and tag to origin (atomic)" log_stage "opening pull request"
git -C "$REPO_ROOT" push --atomic origin "$CURRENT_BRANCH" "$TAG" PR_BODY="Bumps CLI version to \`$NEW_VERSION\`.
log_stage "release triggered — CI workflow will build and publish" Local build-and-test passed (lint, typecheck, test, build).
log_stage "watch progress at: https://github.com/iflytek/skillhub/actions/workflows/release-cli.yml"
After merging, tag and push to trigger the release:
\`\`\`bash
git fetch origin main
git checkout main
git merge --ff-only origin/main
git tag $TAG origin/main
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 fetch origin main"
echo " git tag $TAG origin/main"
echo " git push origin $TAG"
echo ""

View file

@ -2,10 +2,11 @@
# Integration tests for scripts/publish-cli.sh. # Integration tests for scripts/publish-cli.sh.
# #
# The script bumps cli/package.json, commits, tags `cli-vX.Y.Z`, and pushes # The script runs local build-and-test (lint, typecheck, test, build), bumps
# both refs to origin. These tests build a self-contained fake repo for each # cli/package.json, pushes a `release/cli-vX.Y.Z` branch, and opens a PR via
# scenario, using a real bare repository as origin so git fetch / pull / push # `gh pr create`. These tests build a self-contained fake repo per scenario,
# are actually exercised. `npm` is stubbed to keep `npm version` deterministic. # 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 set -euo pipefail
@ -37,8 +38,10 @@ fail() {
# #
# Builds a minimal repo with: # Builds a minimal repo with:
# - cli/package.json at the requested version # - 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) # - scripts/publish-cli.sh (the script under test)
# - bin/npm stub that implements `npm version <bump> --no-git-tag-version` # - 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` # - a sibling bare repo as `origin`
# - HEAD on `main` with the init commit already pushed # - HEAD on `main` with the init commit already pushed
# - optional pre-seeded `cli-v*` tags (created locally AND on origin) # - optional pre-seeded `cli-v*` tags (created locally AND on origin)
@ -53,7 +56,7 @@ init_repo() {
local origin="$repo.origin.git" local origin="$repo.origin.git"
TMP_DIRS+=("$origin") TMP_DIRS+=("$origin")
mkdir -p "$repo/cli" "$repo/scripts" "$repo/bin" mkdir -p "$repo/cli/src/generated" "$repo/scripts" "$repo/bin"
cp "$PUBLISH_SCRIPT" "$repo/scripts/publish-cli.sh" cp "$PUBLISH_SCRIPT" "$repo/scripts/publish-cli.sh"
cat >"$repo/cli/package.json" <<EOF cat >"$repo/cli/package.json" <<EOF
@ -66,40 +69,103 @@ init_repo() {
} }
EOF EOF
# Stub `npm`: only `npm version <patch|minor|major> --no-git-tag-version` is cat >"$repo/cli/src/generated/pkg-info.ts" <<EOF
# supported. Mutates package.json in cwd and echoes `vX.Y.Z` (matching real // Generated by scripts/generate-pkg-info.ts - do not edit by hand.
# npm behaviour the script depends on). export const PKG_NAME = "@astron-team/skillhub"
cat >"$repo/bin/npm" <<'EOF' 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 #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
if [[ "${1:-}" != "version" ]]; then
echo "npm stub: unsupported subcommand: $*" >&2 if [[ -n "${BUN_FAIL_AT:-}" ]] && [[ "$*" == *"$BUN_FAIL_AT"* ]]; then
echo "stub bun: forced failure at: $*" >&2
exit 1 exit 1
fi fi
BUMP="$2"
node - "$PWD/package.json" "$BUMP" <<'NODE'
const fs = require("fs");
const path = process.argv[2];
const bump = process.argv[3];
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
const parts = pkg.version.split(".").map(Number);
if (bump === "patch") parts[2] += 1;
else if (bump === "minor") { parts[1] += 1; parts[2] = 0; }
else if (bump === "major") { parts[0] += 1; parts[1] = 0; parts[2] = 0; }
else throw new Error("unexpected bump: " + bump);
const next = parts.join(".");
pkg.version = next;
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
console.log("v" + next);
NODE
EOF
chmod +x "$repo/bin/npm"
# Ignore test scaffolding files so they don't make `git status` dirty. 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 cat >"$repo/.gitignore" <<EOF
stdout.log stdout.log
stderr.log stderr.log
git-push-log.txt gh-stub.log
bin-git/ bin-git/
EOF EOF
@ -108,7 +174,8 @@ EOF
git -C "$repo" config user.name "Test User" git -C "$repo" config user.name "Test User"
git -C "$repo" config user.email "test@example.com" git -C "$repo" config user.email "test@example.com"
git -C "$repo" remote add origin "$origin" git -C "$repo" remote add origin "$origin"
git -C "$repo" add cli/package.json scripts/publish-cli.sh bin/npm .gitignore 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" commit -q -m "init"
git -C "$repo" push -q -u origin main git -C "$repo" push -q -u origin main
@ -119,24 +186,32 @@ EOF
done done
} }
# run_publish <repo> <bump> [stdin] # run_publish <repo> <bump> [stdin] [extra_env=...]
# Writes stdout to $repo/stdout.log and stderr to $repo/stderr.log. #
# Prints the exit code on stdout. # 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() { run_publish() {
local repo="$1" local repo="$1"
local bump="$2" local bump="$2"
local input="${3-}" local input="${3-}"
local extra="${4-}"
local status=0 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 if [[ -n "$input" ]]; then
printf '%s' "$input" | env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE \ printf '%s' "$input" | "${cmd[@]}" >"$repo/stdout.log" 2>"$repo/stderr.log" || status=$?
REPO_ROOT="$repo" PATH="$repo/bin:$PATH" \
bash "$repo/scripts/publish-cli.sh" "$bump" \
>"$repo/stdout.log" 2>"$repo/stderr.log" || status=$?
else else
env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE \ "${cmd[@]}" >"$repo/stdout.log" 2>"$repo/stderr.log" || status=$?
REPO_ROOT="$repo" PATH="$repo/bin:$PATH" \
bash "$repo/scripts/publish-cli.sh" "$bump" \
>"$repo/stdout.log" 2>"$repo/stderr.log" || status=$?
fi fi
echo "$status" echo "$status"
} }
@ -157,10 +232,9 @@ grep -F "Usage:" "$REPO1/stderr.log" >/dev/null
echo "[test] dirty working tree aborts" echo "[test] dirty working tree aborts"
REPO2="$(new_tmp)" REPO2="$(new_tmp)"
init_repo "$REPO2" init_repo "$REPO2"
touch "$REPO2/dirty.txt" echo "junk" > "$REPO2/cli/package.json"
status="$(run_publish "$REPO2" "patch")" status="$(run_publish "$REPO2" "patch")"
[[ "$status" -ne 0 ]] || fail "expected non-zero exit for dirty tree" [[ "$status" -ne 0 ]] || fail "expected non-zero exit for dirty tree"
grep -F "checking git working tree" "$REPO2/stdout.log" >/dev/null
grep -F "git working tree is not clean" "$REPO2/stderr.log" >/dev/null grep -F "git working tree is not clean" "$REPO2/stderr.log" >/dev/null
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
@ -176,149 +250,135 @@ grep -F "releases must be cut from 'main'" "$REPO3/stderr.log" >/dev/null
grep -F "feature/x" "$REPO3/stderr.log" >/dev/null grep -F "feature/x" "$REPO3/stderr.log" >/dev/null
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# Test 4: package.json behind latest cli-v* tag → baseline sync, then bump # Test 4: gh auth fails → abort early
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
echo "[test] baseline sync from latest cli-v* tag, then bump + push" echo "[test] gh auth failure aborts"
REPO4="$(new_tmp)" REPO4="$(new_tmp)"
init_repo "$REPO4" "0.1.0" "cli-v0.2.0" init_repo "$REPO4"
status="$(run_publish "$REPO4" "patch" $'y\n')" status="$(run_publish "$REPO4" "patch" "" "GH_FAIL_AT=auth")"
[[ "$status" -eq 0 ]] || { cat "$REPO4/stderr.log" >&2; fail "expected success, got $status"; } [[ "$status" -ne 0 ]] || fail "expected non-zero exit when gh auth fails"
grep -F "syncing package.json 0.1.0 -> 0.2.0 (from cli-v0.2.0)" "$REPO4/stdout.log" >/dev/null grep -F "gh CLI is not authenticated" "$REPO4/stderr.log" >/dev/null
grep -F "bumping version (patch)" "$REPO4/stdout.log" >/dev/null
grep -F "new version: 0.2.1 (tag: cli-v0.2.1)" "$REPO4/stdout.log" >/dev/null
grep -F '"version": "0.2.1"' "$REPO4/cli/package.json" >/dev/null
git -C "$REPO4" rev-parse "cli-v0.2.1" >/dev/null \
|| fail "local tag cli-v0.2.1 missing"
git -C "$REPO4" log --oneline | grep -F "chore(cli): bump version to 0.2.1" >/dev/null
git -C "$REPO4.origin.git" rev-parse "cli-v0.2.1" >/dev/null \
|| fail "origin tag cli-v0.2.1 missing — atomic push not delivered"
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# Test 5: no cli-v* tags → fall back to package.json # Test 5: release branch already exists → abort before build (fail-fast)
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
echo "[test] no cli-v* tags falls back to package.json" echo "[test] existing release branch aborts before build"
REPO5="$(new_tmp)" REPO5="$(new_tmp)"
init_repo "$REPO5" "0.1.0" init_repo "$REPO5" "0.1.0" "cli-v0.1.0"
status="$(run_publish "$REPO5" "minor" $'y\n')" # Baseline is cli-v0.1.0, patch target is cli-v0.1.1, branch=release/cli-v0.1.1.
[[ "$status" -eq 0 ]] || { cat "$REPO5/stderr.log" >&2; fail "expected success, got $status"; } # Create that branch locally so the pre-flight check catches it.
grep -F "no cli-v* tags found, bumping from package.json" "$REPO5/stdout.log" >/dev/null git -C "$REPO5" branch "release/cli-v0.1.1"
grep -F "new version: 0.2.0 (tag: cli-v0.2.0)" "$REPO5/stdout.log" >/dev/null status="$(run_publish "$REPO5" "patch")"
git -C "$REPO5.origin.git" rev-parse "cli-v0.2.0" >/dev/null \ [[ "$status" -ne 0 ]] || fail "expected non-zero exit when release branch exists"
|| fail "origin tag cli-v0.2.0 missing" 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 → revert package.json, no commit, no tag # Test 6: confirmation cancel → no branch, no commit, no push
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
echo "[test] confirmation cancel reverts everything" echo "[test] confirmation cancel leaves no side effects"
REPO6="$(new_tmp)" REPO6="$(new_tmp)"
init_repo "$REPO6" "0.1.0" "cli-v0.1.0" init_repo "$REPO6" "0.1.0" "cli-v0.1.0"
INITIAL_HEAD="$(git -C "$REPO6" rev-parse HEAD)" INITIAL_HEAD="$(git -C "$REPO6" rev-parse HEAD)"
status="$(run_publish "$REPO6" "patch" $'n\n')" status="$(run_publish "$REPO6" "patch" $'n\n')"
[[ "$status" -ne 0 ]] || fail "expected non-zero exit on cancel" [[ "$status" -ne 0 ]] || fail "expected non-zero exit on cancel"
grep -F "release cancelled" "$REPO6/stderr.log" >/dev/null grep -F "release cancelled" "$REPO6/stderr.log" >/dev/null
grep -F '"version": "0.1.0"' "$REPO6/cli/package.json" >/dev/null \
|| fail "package.json not reverted to 0.1.0 after cancel"
[[ "$(git -C "$REPO6" rev-parse HEAD)" == "$INITIAL_HEAD" ]] \ [[ "$(git -C "$REPO6" rev-parse HEAD)" == "$INITIAL_HEAD" ]] \
|| fail "HEAD advanced after cancel — extra commit was made" || fail "HEAD advanced after cancel"
if git -C "$REPO6" rev-parse -q --verify "refs/tags/cli-v0.1.1" >/dev/null 2>&1; then [[ "$(git -C "$REPO6" rev-parse --abbrev-ref HEAD)" == "main" ]] \
fail "tag cli-v0.1.1 must not exist after cancel" || 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 fi
[[ -z "$(git -C "$REPO6" status --porcelain)" ]] \ [[ -z "$(git -C "$REPO6" status --porcelain)" ]] \
|| fail "working tree not clean after cancel — revert incomplete" || fail "working tree not clean after cancel"
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# Test 7: happy path — atomic push delivers branch + tag together # Test 7: happy path — branch pushed, PR opened, returned to main
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
echo "[test] happy path pushes branch and tag atomically" echo "[test] happy path pushes branch and opens PR"
REPO7="$(new_tmp)" REPO7="$(new_tmp)"
init_repo "$REPO7" "0.5.0" init_repo "$REPO7" "0.5.0"
status="$(run_publish "$REPO7" "patch" $'y\n')" status="$(run_publish "$REPO7" "patch" $'y\n')"
[[ "$status" -eq 0 ]] || { cat "$REPO7/stderr.log" >&2; fail "expected success, got $status"; } [[ "$status" -eq 0 ]] || { cat "$REPO7/stderr.log" >&2; fail "expected success, got $status"; }
ORIGIN7="$REPO7.origin.git"
git -C "$ORIGIN7" rev-parse "cli-v0.5.1" >/dev/null \
|| fail "origin missing tag cli-v0.5.1"
ORIGIN_HEAD="$(git -C "$ORIGIN7" rev-parse main)"
LOCAL_HEAD="$(git -C "$REPO7" rev-parse main)"
[[ "$ORIGIN_HEAD" == "$LOCAL_HEAD" ]] \
|| fail "origin/main HEAD did not advance to match local main"
TAG_COMMIT="$(git -C "$ORIGIN7" rev-parse "cli-v0.5.1^{commit}")"
[[ "$TAG_COMMIT" == "$ORIGIN_HEAD" ]] \
|| fail "origin tag cli-v0.5.1 does not point to origin/main HEAD"
grep -F "release triggered" "$REPO7/stdout.log" >/dev/null
# ---------------------------------------------------------------------------- # Returned to main with branch deleted locally.
# Test 8: push failure → script exits non-zero (commit + tag stay local) [[ "$(git -C "$REPO7" rev-parse --abbrev-ref HEAD)" == "main" ]] \
# || fail "not back on main after success"
# Use a git wrapper that fails only on `git push`, so pull/fetch succeed if git -C "$REPO7" rev-parse -q --verify "refs/heads/release/cli-v0.5.1" >/dev/null 2>&1; then
# but the final push does not. fail "local release branch should be deleted after success"
# ----------------------------------------------------------------------------
echo "[test] push failure surfaces error"
REPO8="$(new_tmp)"
init_repo "$REPO8" "0.6.0"
mkdir -p "$REPO8/bin-git"
cat >"$REPO8/bin-git/git" <<'WRAPPER'
#!/usr/bin/env bash
if [[ "$*" == *"push"* ]]; then
echo "fatal: could not read from remote repository." >&2
exit 128
fi fi
exec /usr/bin/git "$@"
WRAPPER # Branch on origin with bump commit.
chmod +x "$REPO8/bin-git/git" ORIGIN7="$REPO7.origin.git"
status=0 git -C "$ORIGIN7" rev-parse "refs/heads/release/cli-v0.5.1" >/dev/null \
printf 'y\n' | env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE \ || fail "origin missing release branch"
REPO_ROOT="$REPO8" PATH="$REPO8/bin-git:$REPO8/bin:$PATH" \ TIP_MSG="$(git -C "$ORIGIN7" log -1 --format=%s "refs/heads/release/cli-v0.5.1")"
bash "$REPO8/scripts/publish-cli.sh" "patch" \ [[ "$TIP_MSG" == "chore(cli): bump version to 0.5.1" ]] \
>"$REPO8/stdout.log" 2>"$REPO8/stderr.log" || status=$? || fail "unexpected commit message on release branch: $TIP_MSG"
[[ "$status" -ne 0 ]] || fail "expected non-zero exit when push fails"
git -C "$REPO8" rev-parse "cli-v0.6.1" >/dev/null \ # pkg-info.ts AND package.json both updated in the bump commit.
|| fail "local tag cli-v0.6.1 missing after push failure" git -C "$ORIGIN7" show "refs/heads/release/cli-v0.5.1:cli/package.json" \
git -C "$REPO8" log --oneline | grep -F "chore(cli): bump version to 0.6.1" >/dev/null \ | grep -F '"version": "0.5.1"' >/dev/null \
|| fail "local bump commit missing after push failure" || 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 9: unpushed detection catches "branch pushed, tag not pushed" state # Test 8: baseline from latest cli-v* tag (not package.json)
#
# 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" 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)" REPO9="$(new_tmp)"
init_repo "$REPO9" "0.7.0" init_repo "$REPO9" "0.6.0"
cd "$REPO9/cli" status="$(run_publish "$REPO9" "patch" $'y\n' "GH_FAIL_AT=pr-create")"
node -e " [[ "$status" -ne 0 ]] || fail "expected non-zero exit when gh pr create fails"
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); # Recovery instructions emitted by the trap.
pkg.version = '0.7.1'; grep -F "release branch 'release/cli-v0.6.1' was pushed but PR creation failed" \
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); "$REPO9/stderr.log" >/dev/null \
" || { cat "$REPO9/stderr.log" >&2; fail "missing pushed-stage recovery message"; }
cd "$REPO9" grep -F "git push origin --delete release/cli-v0.6.1" "$REPO9/stderr.log" >/dev/null \
git -C "$REPO9" add cli/package.json || fail "missing rollback hint in recovery message"
git -C "$REPO9" commit -q -m "chore(cli): bump version to 0.7.1"
git -C "$REPO9" tag "cli-v0.7.1" # Branch is on origin (push succeeded before gh failed).
git -C "$REPO9" push -q origin main git -C "$REPO9.origin.git" rev-parse "refs/heads/release/cli-v0.6.1" >/dev/null \
# Tag NOT pushed — simulates atomic push partial failure recovery || fail "expected branch on origin after push, before failed PR"
status="$(run_publish "$REPO9" "patch")"
[[ "$status" -ne 0 ]] || fail "expected non-zero exit when unpushed tag detected"
grep -F "Unpushed tags" "$REPO9/stderr.log" >/dev/null \
|| { cat "$REPO9/stderr.log" >&2; fail "expected unpushed tag warning"; }
grep -F "cli-v0.7.1" "$REPO9/stderr.log" >/dev/null \
|| fail "expected cli-v0.7.1 in unpushed tag warning"
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# Test 10: --atomic flag is actually passed to git push # Test 10: push fails → committed-stage cleanup, branch deleted locally
#
# Use a git wrapper to capture the push command and verify --atomic is present.
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
echo "[test] push uses --atomic flag" echo "[test] push failure rolls back local branch"
REPO10="$(new_tmp)" REPO10="$(new_tmp)"
init_repo "$REPO10" "0.8.0" init_repo "$REPO10" "0.7.0"
mkdir -p "$REPO10/bin-git" mkdir -p "$REPO10/bin-git"
cat >"$REPO10/bin-git/git" <<'WRAPPER' cat >"$REPO10/bin-git/git" <<'WRAPPER'
#!/usr/bin/env bash #!/usr/bin/env bash
if [[ "$*" == *"push"* ]]; then if [[ "${1:-}" == "-C" && "${3:-}" == "push" ]]; then
echo "GIT_PUSH_ARGS: $*" >> "$REPO_ROOT/git-push-log.txt" echo "fatal: forced push failure" >&2
exit 128
fi fi
exec /usr/bin/git "$@" exec /usr/bin/git "$@"
WRAPPER WRAPPER
@ -326,10 +386,59 @@ chmod +x "$REPO10/bin-git/git"
status=0 status=0
printf 'y\n' | env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE \ 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" \ REPO_ROOT="$REPO10" PATH="$REPO10/bin-git:$REPO10/bin:$PATH" \
GH_LOG_FILE="$REPO10/gh-stub.log" \
bash "$REPO10/scripts/publish-cli.sh" "patch" \ bash "$REPO10/scripts/publish-cli.sh" "patch" \
>"$REPO10/stdout.log" 2>"$REPO10/stderr.log" || status=$? >"$REPO10/stdout.log" 2>"$REPO10/stderr.log" || status=$?
[[ "$status" -eq 0 ]] || { cat "$REPO10/stderr.log" >&2; fail "expected success, got $status"; } [[ "$status" -ne 0 ]] || fail "expected non-zero exit when push fails"
grep -F -- "--atomic" "$REPO10/git-push-log.txt" >/dev/null \
|| { cat "$REPO10/git-push-log.txt" >&2; fail "git push did not include --atomic flag"; } 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" echo "all tests passed"