From 378216c6da2c26a79c4199f8a8f9fc8c44ea31df Mon Sep 17 00:00:00 2001 From: Cheney <970320820@qq.com> Date: Tue, 12 May 2026 10:32:06 +0800 Subject: [PATCH] feat(cli): add automated build and publish workflow - Add release-cli.yml GitHub Actions workflow: build, test, npm publish, and GitHub Release triggered by cli-v* tags - Rewrite scripts/publish-cli.sh: local bump + commit + tag + push, enforces main branch, idempotent tag checks - Add concurrency group and release idempotency to workflow - Add make publish-cli / publish-cli-minor / publish-cli-major targets - Add cli/RELEASE.md documenting the full release process --- .github/workflows/release-cli.yml | 248 ++++++++++++++++++++++++++++ Makefile | 32 +--- cli/RELEASE.md | 146 +++++++++++++++++ scripts/publish-cli.sh | 264 ++++++++---------------------- 4 files changed, 470 insertions(+), 220 deletions(-) create mode 100644 .github/workflows/release-cli.yml create mode 100644 cli/RELEASE.md diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml new file mode 100644 index 00000000..7a7287c8 --- /dev/null +++ b/.github/workflows/release-cli.yml @@ -0,0 +1,248 @@ +name: Release CLI + +on: + push: + tags: ['cli-v*'] + workflow_dispatch: + inputs: + tag: + description: 'Tag to release (e.g. cli-v0.1.5)' + required: true + skip_npm: + description: 'Skip npm publish' + type: boolean + default: false + +permissions: + contents: write + +concurrency: + group: release-cli-${{ github.event.inputs.tag || github.ref_name }} + cancel-in-progress: false + +jobs: + build-and-test: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.extract.outputs.version }} + package_name: ${{ steps.extract.outputs.package_name }} + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Extract version from tag + id: extract + working-directory: cli + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + if [[ ! "$TAG" =~ ^cli-v([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?)$ ]]; then + echo "Invalid tag format: $TAG (expected cli-vX.Y.Z)" + exit 1 + fi + VERSION="${BASH_REMATCH[1]}" + + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.version = '$VERSION'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + + PACKAGE_NAME=$(node -p "require('./package.json').name") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "package_name=$PACKAGE_NAME" >> "$GITHUB_OUTPUT" + echo "Version set to: $VERSION" + echo "Package name: $PACKAGE_NAME" + + - name: Install dependencies + working-directory: cli + run: bun install --frozen-lockfile + + - name: Run linter + working-directory: cli + run: bun run lint + + - name: Run type check + working-directory: cli + run: bun run typecheck + + - name: Run tests + working-directory: cli + run: bun test + + - name: Build CLI + working-directory: cli + run: bun run build + + - name: Verify built CLI + working-directory: cli + run: | + node dist/index.js version + RUNTIME_VERSION=$(node dist/index.js version | sed -E 's/^SkillHub CLI //') + if [ "$RUNTIME_VERSION" != "${{ steps.extract.outputs.version }}" ]; then + echo "Version mismatch: runtime=$RUNTIME_VERSION, tag=${{ steps.extract.outputs.version }}" + exit 1 + fi + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: cli-dist + path: | + cli/dist/ + cli/package.json + cli/README.md + cli/LICENSE + retention-days: 7 + + publish-npm: + needs: build-and-test + runs-on: ubuntu-latest + if: ${{ !inputs.skip_npm }} + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Set version from tag + working-directory: cli + run: | + VERSION="${{ needs.build-and-test.outputs.version }}" + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.version = '$VERSION'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + + - name: Install dependencies + working-directory: cli + run: bun install --frozen-lockfile + + - name: Build CLI + working-directory: cli + run: bun run build + + - name: Check if version exists on npm + id: check_npm + env: + NPM_REGISTRY: ${{ vars.NPM_REGISTRY || 'https://registry.npmjs.org' }} + run: | + 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 + 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" + fi + + - name: Configure npm authentication + if: steps.check_npm.outputs.exists == 'false' + env: + NPM_REGISTRY: ${{ vars.NPM_REGISTRY || 'https://registry.npmjs.org' }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + REGISTRY_HOST="${NPM_REGISTRY#http://}" + REGISTRY_HOST="${REGISTRY_HOST#https://}" + REGISTRY_HOST="${REGISTRY_HOST%/}" + + cat > ~/.npmrc < "skillhub-cli-${VERSION}.tar.gz.sha256" + sha256sum "skillhub-cli-${VERSION}.zip" > "skillhub-cli-${VERSION}.zip.sha256" + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + VERSION="${{ needs.build-and-test.outputs.version }}" + PACKAGE_NAME="${{ needs.build-and-test.outputs.package_name }}" + + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $TAG already exists, skipping" + exit 0 + fi + + # Generate release notes + cat > release-notes.md < 0.1.6 +make publish-cli-minor # minor: 0.1.5 -> 0.2.0 +make publish-cli-major # major: 0.1.5 -> 1.0.0 +``` + +[`scripts/publish-cli.sh`](../scripts/publish-cli.sh) performs the following steps: + +1. Verify the working tree is clean +2. Require the current branch to be `main`, otherwise abort +3. `git pull --ff-only` from `origin/main` +4. Fetch remote tags and align `package.json` with the latest `cli-v*` tag +5. Compute the new version via `npm version ` +6. Verify the new tag does not exist locally or on origin +7. After interactive confirmation: commit the bump, create the `cli-vX.Y.Z` tag, push both commit and tag to origin + +Pushing the tag triggers CI — no further manual action required. + +### CI Workflow + +[`release-cli.yml`](../.github/workflows/release-cli.yml) contains three jobs: + +1. **build-and-test** + - Extract version from tag name (`cli-v0.1.6` → `0.1.6`) and write it into `cli/package.json` + - Install deps, lint, typecheck, test, build + - Verify the built CLI's runtime version matches the tag + +2. **publish-npm** + - Skip if the target version already exists on the registry + - Configure `~/.npmrc` and run `npm publish --access public` + +3. **create-release** + - Package `dist/` + README + LICENSE as `tar.gz` and `zip` + - Generate SHA256 checksums + - Create a GitHub Release and upload artifacts + +### Verify Release + +- Workflow: https://github.com/iflytek/skillhub/actions/workflows/release-cli.yml +- Release: https://github.com/iflytek/skillhub/releases +- npm: `npm view @astron-team/skillhub@` + +## Release Audit Trail + +GitHub Actions automatically records on each workflow run page: + +- **Triggering user** (the developer who pushed the tag, i.e. `github.actor`) +- **Trigger event** (`push` tag or `workflow_dispatch`) +- **Tag name and commit SHA** + +The team can review the full audit trail in the Actions tab without any extra configuration. + +## Manual Trigger + +From the Actions UI: + +1. Actions → Release CLI → "Run workflow" +2. Enter an existing tag name matching `cli-vX.Y.Z` +3. Optionally enable skip npm publish + +## Troubleshooting + +### `releases must be cut from 'main'` + +Switch back to `main`, pull the latest, and retry. + +### `git working tree is not clean` + +Commit or stash local changes first. + +### `tag cli-vX.Y.Z already exists` + +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. + +### 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 Forbidden**: Package scope doesn't match token permissions — confirm publish rights for the `@astron-team` org +- **E404**: The registry doesn't host this scope — check `NPM_REGISTRY` + +### Build / Test Fails + +Reproduce locally: + +```bash +make lint-cli && make typecheck-cli && make test-cli && make build-cli +``` + +Confirm the Bun version matches `packageManager` in [`cli/package.json`](./package.json). + +### Version Mismatch (runtime ≠ tag) + +CI runs `node dist/index.js version` and requires the output to match the tag. If the CLI's `version` command implementation changes, update the verification logic in [`release-cli.yml`](../.github/workflows/release-cli.yml) accordingly. + +## Tag Naming Convention + +- CLI releases: `cli-v*` (e.g., `cli-v0.1.6`) +- Repository releases: `v*` (e.g., `v0.3.0`) + +The two tag namespaces are independent, allowing CLI and server to version separately. diff --git a/scripts/publish-cli.sh b/scripts/publish-cli.sh index 221c4158..3915cc8a 100755 --- a/scripts/publish-cli.sh +++ b/scripts/publish-cli.sh @@ -1,20 +1,26 @@ #!/usr/bin/env bash +# 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. + set -euo pipefail REPO_ROOT="${REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" CLI_DIR="$REPO_ROOT/cli" -ENV_LOCAL="$CLI_DIR/.env.local" PACKAGE_JSON="$CLI_DIR/package.json" -PKG_INFO_TS="$CLI_DIR/src/generated/pkg-info.ts" -DIST_ENTRY="$CLI_DIR/dist/index.js" log_stage() { echo "[publish-cli] $1" } usage() { - echo "Usage: $0 [patch|minor|major|skip]" >&2 + echo "Usage: $0 [patch|minor|major]" >&2 } confirm() { @@ -24,222 +30,90 @@ confirm() { [[ "$answer" =~ ^[Yy]$ ]] } -verify_version_sync() { - local expected_version="$1" - local generated_version - local runtime_version - - generated_version="$(node - "$PKG_INFO_TS" <<'NODE' -const fs = require('fs') -const path = process.argv[2] -const contents = fs.readFileSync(path, 'utf8') -const match = contents.match(/export const PKG_VERSION = ["']([^"']+)["']/) -if (!match) process.exit(1) -process.stdout.write(match[1]) -NODE -)" - - runtime_version="$(node "$DIST_ENTRY" version | sed -E 's/^SkillHub CLI //')" - - if [[ "$generated_version" != "$expected_version" ]]; then - echo "generated PKG_VERSION mismatch: expected $expected_version, got $generated_version" >&2 - exit 1 - fi - - if [[ "$runtime_version" != "$expected_version" ]]; then - echo "built CLI version mismatch: expected $expected_version, got $runtime_version" >&2 - exit 1 - fi -} - -assert_version_not_published() { - local package_name="$1" - local version="$2" - local view_output - - if view_output="$(npm view "${package_name}@${version}" version --registry "$NPM_REGISTRY" 2>&1)"; then - echo "${package_name}@${version} already exists on $NPM_REGISTRY" >&2 - echo "Update cli/package.json to the latest published version before running this release." >&2 - exit 1 - fi - - if echo "$view_output" | grep -Eiq '(E404|404 Not Found|is not in this registry|Not found)'; then - return 0 - fi - - echo "failed to verify whether ${package_name}@${version} exists on $NPM_REGISTRY" >&2 - echo "$view_output" >&2 - exit 1 -} - -next_package_version() { - local version="$1" - local bump_type="$2" - - node - "$version" "$bump_type" <<'NODE' -const version = process.argv[2] -const bumpType = process.argv[3] -const parts = version.split('.').map(Number) - -if (parts.length !== 3 || parts.some(part => !Number.isInteger(part) || part < 0)) { - throw new Error(`unsupported package version: ${version}`) -} - -if (bumpType === 'patch') { - parts[2] += 1 -} else if (bumpType === 'minor') { - parts[1] += 1 - parts[2] = 0 -} else if (bumpType === 'major') { - parts[0] += 1 - parts[1] = 0 - parts[2] = 0 -} else if (bumpType !== 'skip') { - throw new Error(`unsupported bump type: ${bumpType}`) -} - -process.stdout.write(parts.join('.')) -NODE -} - BUMP_TYPE="${1:-patch}" -if [[ "$BUMP_TYPE" != "patch" && "$BUMP_TYPE" != "minor" && "$BUMP_TYPE" != "major" && "$BUMP_TYPE" != "skip" ]]; then +if [[ "$BUMP_TYPE" != "patch" && "$BUMP_TYPE" != "minor" && "$BUMP_TYPE" != "major" ]]; then usage exit 1 fi -if [[ ! -f "$ENV_LOCAL" ]]; then - echo "cli/.env.local not found" >&2 - echo "Copy cli/.env.example to cli/.env.local" >&2 - exit 1 -fi - -log_stage "loading environment" -set -a -# shellcheck disable=SC1090 -source "$ENV_LOCAL" -set +a - -: "${NPM_REGISTRY:=https://registry.npmjs.org}" -: "${DRY_RUN:=false}" - -if [[ -z "${NPM_TOKEN:-}" ]]; then - echo "NPM_TOKEN is required" >&2 - exit 1 -fi - -if [[ -z "${NPM_ORG:-}" ]]; then - echo "NPM_ORG is required" >&2 - exit 1 -fi - -log_stage "validating package metadata" -PACKAGE_NAME="$(node -p "require('$PACKAGE_JSON').name ?? ''")" -PACKAGE_VERSION="$(node -p "require('$PACKAGE_JSON').version ?? ''")" -PACKAGE_ACCESS="$(node -p "require('$PACKAGE_JSON').publishConfig?.access ?? ''")" - -if [[ "$PACKAGE_NAME" != "@${NPM_ORG}/"* ]]; then - echo "package name must match @${NPM_ORG}/* (got $PACKAGE_NAME)" >&2 - exit 1 -fi - -if [[ -z "$PACKAGE_VERSION" ]]; then - echo "package version is required" >&2 - exit 1 -fi - -if [[ "$PACKAGE_ACCESS" != "public" ]]; then - echo "publishConfig.access must be public" >&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" >&2 + echo "git working tree is not clean — commit or stash changes first" >&2 exit 1 fi -if [[ "$BUMP_TYPE" == "skip" ]]; then - NEW_VERSION="$PACKAGE_VERSION" -else - NEW_VERSION="$(next_package_version "$PACKAGE_VERSION" "$BUMP_TYPE")" +CURRENT_BRANCH="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD)" +if [[ "$CURRENT_BRANCH" != "main" ]]; then + echo "releases must be cut from 'main' (current: '$CURRENT_BRANCH')" >&2 + exit 1 fi -log_stage "checking registry version" -assert_version_not_published "$PACKAGE_NAME" "$NEW_VERSION" +log_stage "pulling latest from origin/$CURRENT_BRANCH" +git -C "$REPO_ROOT" pull --ff-only origin "$CURRENT_BRANCH" -if [[ "$BUMP_TYPE" != "skip" ]]; then - if ! confirm "Proceed with version bump ($BUMP_TYPE)?"; then - echo "version bump cancelled" >&2 - exit 1 +log_stage "fetching tags from origin" +git -C "$REPO_ROOT" fetch --tags --prune origin + +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 + 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 "bumping version ($BUMP_TYPE)" - ( - cd "$CLI_DIR" - npm version "$BUMP_TYPE" --no-git-tag-version - ) +else + log_stage "no cli-v* tags found, bumping from package.json" fi -ACTUAL_VERSION="$(node -p "require('$PACKAGE_JSON').version ?? ''")" -if [[ "$ACTUAL_VERSION" != "$NEW_VERSION" ]]; then - echo "npm version produced $ACTUAL_VERSION, expected $NEW_VERSION" >&2 +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}" + +if [[ -z "$NEW_VERSION" ]]; then + echo "failed to parse version from npm output: $NPM_VERSION_OUTPUT" >&2 + git -C "$REPO_ROOT" checkout -- "$PACKAGE_JSON" exit 1 fi -log_stage "running preflight build" -( - cd "$CLI_DIR" - bun run build -) +TAG="cli-v${NEW_VERSION}" -log_stage "running preflight tests" -( - cd "$CLI_DIR" - bun run test -) - -log_stage "verifying built version" -verify_version_sync "$NEW_VERSION" - -log_stage "running preflight pack" -( - cd "$CLI_DIR" - npm pack --dry-run -) - -log_stage "ready to publish $PACKAGE_NAME@$NEW_VERSION" - -if [[ "$DRY_RUN" == "true" ]]; then - log_stage "DRY_RUN=true, skipping npm publish" - exit 0 +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 ! confirm "Publish $PACKAGE_NAME@$NEW_VERSION to $NPM_REGISTRY?"; then - echo "publish cancelled" >&2 - exit 2 +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 -NPM_CONFIG_FILE="$(mktemp)" -cleanup() { - rm -f "$NPM_CONFIG_FILE" -} -trap cleanup EXIT +log_stage "new version: $NEW_VERSION (tag: $TAG)" -REGISTRY_HOST="${NPM_REGISTRY#http://}" -REGISTRY_HOST="${REGISTRY_HOST#https://}" -REGISTRY_HOST="${REGISTRY_HOST%/}" +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 +fi -cat >"$NPM_CONFIG_FILE" <