From 3ced92ff77c7a9dcc5eec3d0f451e98e38f9bdb7 Mon Sep 17 00:00:00 2001 From: killagu-claw Date: Fri, 27 Feb 2026 00:13:39 +0800 Subject: [PATCH] fix(ci): make release workflow retry-safe and resilient Add retry detection to skip version bump when re-running a failed release. Replace pnpm -r publish with per-package publish script that skips already-published versions, retries failures, and handles race conditions. - Add scripts/publish.js for resilient per-package publishing - Detect existing version tag/commit on retry to skip duplicate bump - Separate push into own step for better failure granularity - Make GitHub Release creation idempotent (check before create) - Run cnpm sync and summary even on partial publish failure - Fix broken error handling (duplicate || tail on publish) Co-Authored-By: Claude Opus 4.6 --- .github/workflows/release.yml | 116 +++++++++++++++++++------- scripts/publish.js | 149 ++++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+), 31 deletions(-) create mode 100644 scripts/publish.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff489dd928..c345f0b61d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,8 @@ name: Manual Release -# Fixed: Now properly uses prerelease_tag input for npm publish -# when version_type starts with 'pre' +# Retry-safe: if a previous release failed after version bump, re-running +# with the same parameters will detect the existing version bump and skip it. +# The publish script handles already-published packages gracefully. on: workflow_dispatch: @@ -45,7 +46,7 @@ jobs: runs-on: ubuntu-latest concurrency: - group: release-${{ github.workflow }}-#${{ github.event.pull_request.number || github.head_ref || github.ref }} + group: release-${{ github.event.inputs.branch }} cancel-in-progress: false permissions: @@ -59,7 +60,6 @@ jobs: with: ref: ${{ github.event.inputs.branch }} fetch-depth: 0 - # Use git token for checkout and pushing token: ${{ secrets.GIT_TOKEN }} - name: Setup pnpm @@ -80,31 +80,73 @@ jobs: git config --local user.email "action@github.com" git config --local user.name "GitHub Action" + # Detect if this is a retry of a previously failed release. + # Skips version bump if the commit/tag already exist on HEAD. + - name: Detect existing release + id: detect + if: ${{ github.event.inputs.dry_run != 'true' }} + run: | + # Case 1: HEAD already has a version tag (version bump + push fully succeeded) + EXISTING_TAG=$(git describe --tags --exact-match HEAD 2>/dev/null || true) + if [[ -n "$EXISTING_TAG" && "$EXISTING_TAG" == v* ]]; then + echo "๐Ÿ”„ Retry detected: found tag $EXISTING_TAG on HEAD" + echo "skip_version_bump=true" >> $GITHUB_OUTPUT + echo "need_push=false" >> $GITHUB_OUTPUT + echo "NEW_TAG=$EXISTING_TAG" >> $GITHUB_ENV + exit 0 + fi + + # Case 2: HEAD is a version bump commit but tag may be missing (partial push) + HEAD_MSG=$(git log -1 --format=%s HEAD) + if [[ "$HEAD_MSG" == chore\(release\):*version\ bump ]]; then + EGG_VERSION=$(node -e "process.stdout.write(JSON.parse(require('fs').readFileSync('./packages/egg/package.json','utf8')).version)") + EXPECTED_TAG="v${EGG_VERSION}" + echo "๐Ÿ”„ Retry detected: version bump commit found (tag: $EXPECTED_TAG)" + + # Create the tag locally if it doesn't exist + if ! git rev-parse "$EXPECTED_TAG" >/dev/null 2>&1; then + echo " Creating missing tag: $EXPECTED_TAG" + git tag "$EXPECTED_TAG" + fi + + echo "skip_version_bump=true" >> $GITHUB_OUTPUT + echo "need_push=true" >> $GITHUB_OUTPUT + echo "NEW_TAG=$EXPECTED_TAG" >> $GITHUB_ENV + exit 0 + fi + + # Case 3: Fresh release + echo "Fresh release: will perform version bump" + echo "skip_version_bump=false" >> $GITHUB_OUTPUT + echo "need_push=true" >> $GITHUB_OUTPUT + - name: Version bump (dry run) if: ${{ github.event.inputs.dry_run == 'true' }} run: | echo "๐Ÿงช Running version bump in dry-run mode..." - if [[ "${{ github.event.inputs.version_type }}" == prerelease* ]]; then + if [[ "${{ github.event.inputs.version_type }}" == pre* ]]; then node scripts/version.js ${{ github.event.inputs.version_type }} --prerelease-tag=${{ github.event.inputs.prerelease_tag }} --dry-run else node scripts/version.js ${{ github.event.inputs.version_type }} --dry-run fi - name: Version bump - if: ${{ github.event.inputs.dry_run != 'true' }} + if: ${{ github.event.inputs.dry_run != 'true' && steps.detect.outputs.skip_version_bump != 'true' }} run: | echo "๐Ÿš€ Running version bump..." - if [[ "${{ github.event.inputs.version_type }}" == prerelease* ]]; then + if [[ "${{ github.event.inputs.version_type }}" == pre* ]]; then node scripts/version.js ${{ github.event.inputs.version_type }} --prerelease-tag=${{ github.event.inputs.prerelease_tag }} else node scripts/version.js ${{ github.event.inputs.version_type }} fi - # Get the new version tag NEW_TAG=$(git describe --tags --abbrev=0) echo "NEW_TAG=$NEW_TAG" >> $GITHUB_ENV - # Push changes and tags + - name: Push version commit and tags + if: ${{ github.event.inputs.dry_run != 'true' && steps.detect.outputs.need_push != 'false' }} + run: | + echo "๐Ÿ“ค Pushing to origin/${{ github.event.inputs.branch }} with tags..." git push origin ${{ github.event.inputs.branch }} --tags - name: Run build @@ -113,29 +155,23 @@ jobs: - name: Publish packages (dry run) if: ${{ github.event.inputs.dry_run == 'true' }} run: | - echo "๐Ÿงช Running publish in dry-run mode..." if [[ "${{ github.event.inputs.version_type }}" == pre* ]]; then - echo "Setting npm tag to: ${{ github.event.inputs.prerelease_tag }}" - pnpm -r publish --dry-run --no-git-checks --access public --tag=${{ github.event.inputs.prerelease_tag }} + node scripts/publish.js --tag=${{ github.event.inputs.prerelease_tag }} --dry-run else - echo "Setting npm tag to: latest" - pnpm -r publish --dry-run --no-git-checks --access public --tag=latest + node scripts/publish.js --tag=latest --dry-run fi - name: Publish packages if: ${{ github.event.inputs.dry_run != 'true' }} run: | - echo "๐Ÿ“ฆ Publishing packages..." if [[ "${{ github.event.inputs.version_type }}" == pre* ]]; then - echo "Setting npm tag to: ${{ github.event.inputs.prerelease_tag }}" - NPM_CONFIG_LOGLEVEL=verbose pnpm -r publish --no-git-checks --access public --provenance --tag=${{ github.event.inputs.prerelease_tag }} || tail -n 100 ~/.npm/_logs/*.log && exit 1 + node scripts/publish.js --tag=${{ github.event.inputs.prerelease_tag }} --provenance else - echo "Setting npm tag to: latest" - NPM_CONFIG_LOGLEVEL=verbose pnpm -r publish --no-git-checks --access public --provenance --tag=latest || tail -n 100 ~/.npm/_logs/*.log || tail -n 100 ~/.npm/_logs/*.log && exit 1 + node scripts/publish.js --tag=latest --provenance fi - name: Create GitHub Release (draft) - if: ${{ github.event.inputs.dry_run != 'true' }} + if: ${{ !cancelled() && github.event.inputs.dry_run != 'true' && env.NEW_TAG != '' }} uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: github-token: ${{ secrets.GIT_TOKEN }} @@ -143,22 +179,35 @@ jobs: const tag = process.env.NEW_TAG; const versionType = '${{ github.event.inputs.version_type }}'; + // Idempotent: check if release already exists (safe for retry) + try { + const existing = await github.rest.repos.getReleaseByTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag: tag, + }); + core.info(`Release already exists: ${existing.data.html_url}`); + core.exportVariable('DRAFT_RELEASE_URL', existing.data.html_url); + return; + } catch (e) { + if (e.status !== 404) throw e; + // 404 = no release exists, proceed to create + } + let releaseBody = `## ๐ŸŽ‰ ${versionType.charAt(0).toUpperCase() + versionType.slice(1)} Release\n\n`; releaseBody += `This release includes ${versionType} version updates for all packages.\n\n`; releaseBody += `### ๐Ÿ“ฆ Published Packages\n\n`; - // Get package versions from the tag const fs = require('fs'); const packagesDirs = ['./packages', './tools', './plugins', './tegg/core', './tegg/plugin', './tegg/standalone']; for (const packagesDir of packagesDirs) { + if (!fs.existsSync(packagesDir)) continue; const packageFolders = fs.readdirSync(packagesDir); for (const folder of packageFolders) { const packageJsonPath = `${packagesDir}/${folder}/package.json`; if (fs.existsSync(packageJsonPath)) { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - if (packageJson.private) { - continue; - } + if (packageJson.private) continue; releaseBody += `- [${packageJson.name}@${packageJson.version}](https://npmjs.com/package/${packageJson.name}/v/${packageJson.version})\n`; } } @@ -180,15 +229,14 @@ jobs: }); core.info(`Created draft release: ${release.data.html_url}`); - - // Set the release URL as an environment variable for use in summary core.exportVariable('DRAFT_RELEASE_URL', release.data.html_url); - name: Sync to cnpm - run: | - node scripts/sync-cnpm.js + if: ${{ !cancelled() && github.event.inputs.dry_run != 'true' }} + run: node scripts/sync-cnpm.js - name: Summary + if: ${{ !cancelled() }} run: | echo "## ๐ŸŽ‰ Release Summary" >> $GITHUB_STEP_SUMMARY @@ -203,15 +251,21 @@ jobs: fi echo "- Status: **Dry run - no changes made**" >> $GITHUB_STEP_SUMMARY else - echo "### โœ… Release Completed" >> $GITHUB_STEP_SUMMARY + if [ "${{ steps.detect.outputs.skip_version_bump }}" == "true" ]; then + echo "### ๐Ÿ”„ Retry Release Completed" >> $GITHUB_STEP_SUMMARY + else + echo "### โœ… Release Completed" >> $GITHUB_STEP_SUMMARY + fi echo "- Version bump: **${{ github.event.inputs.version_type }}**" >> $GITHUB_STEP_SUMMARY echo "- Branch: **${{ github.event.inputs.branch }}**" >> $GITHUB_STEP_SUMMARY - echo "- New tag: **$NEW_TAG**" >> $GITHUB_STEP_SUMMARY + echo "- Tag: **$NEW_TAG**" >> $GITHUB_STEP_SUMMARY if [[ "${{ github.event.inputs.version_type }}" == pre* ]]; then echo "- npm tag: **${{ github.event.inputs.prerelease_tag }}**" >> $GITHUB_STEP_SUMMARY else echo "- npm tag: **latest**" >> $GITHUB_STEP_SUMMARY fi echo "- Packages published to npm" >> $GITHUB_STEP_SUMMARY - echo "- Draft GitHub release created: [View Draft Release]($DRAFT_RELEASE_URL)" >> $GITHUB_STEP_SUMMARY + if [ -n "$DRAFT_RELEASE_URL" ]; then + echo "- GitHub Release: [View Draft]($DRAFT_RELEASE_URL)" >> $GITHUB_STEP_SUMMARY + fi fi diff --git a/scripts/publish.js b/scripts/publish.js new file mode 100644 index 0000000000..107bc8ae97 --- /dev/null +++ b/scripts/publish.js @@ -0,0 +1,149 @@ +#!/usr/bin/env node + +/** + * Resilient per-package publish script. + * + * Unlike `pnpm -r publish`, this script: + * - Skips packages that are already published on npm (safe for retries) + * - Publishes each package individually so one failure doesn't block others + * - Retries failed packages once + * - Exits 0 only when all packages are published successfully + * + * Usage: + * node scripts/publish.js --tag=latest [--provenance] [--dry-run] + */ + +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; + +import { getPublishablePackages } from './utils.js'; + +const args = process.argv.slice(2); +const isDryRun = args.includes('--dry-run'); +const useProvenance = args.includes('--provenance'); + +let npmTag = 'latest'; +const tagArg = args.find(arg => arg.startsWith('--tag=')); +if (tagArg) { + npmTag = tagArg.split('=')[1]; +} + +const baseDir = path.join(import.meta.dirname, '..'); +const packages = getPublishablePackages(baseDir); + +console.log(`๐Ÿ“ฆ Publishing ${packages.length} packages (tag: ${npmTag}${isDryRun ? ', dry-run' : ''}${useProvenance ? ', provenance' : ''})`); + +/** + * Check if a specific version of a package is already published on npm. + */ +function isPublished(name, version) { + try { + const result = execFileSync('npm', ['view', `${name}@${version}`, 'version'], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 15000, + }).trim(); + return result === version; + } catch { + // Could be 404 (not published) or network error. + // Either way, we should attempt to publish. + return false; + } +} + +/** + * Publish a single package using pnpm --filter (preserves workspace context + * so that workspace: protocol references are properly resolved). + */ +function publishOne(pkg) { + const publishArgs = [ + '--filter', pkg.name, + 'publish', '--no-git-checks', '--access', 'public', '--tag', npmTag, + ]; + if (useProvenance) publishArgs.push('--provenance'); + if (isDryRun) publishArgs.push('--dry-run'); + + execFileSync('pnpm', publishArgs, { + cwd: baseDir, + stdio: 'inherit', + env: { ...process.env, NPM_CONFIG_LOGLEVEL: 'verbose' }, + timeout: 120000, + }); +} + +const published = []; +const skipped = []; +const toRetry = []; + +for (const pkg of packages) { + const label = `${pkg.name}@${pkg.version}`; + + // Skip packages already on npm (safe for retries) + if (!isDryRun && isPublished(pkg.name, pkg.version)) { + console.log(` โญ๏ธ ${label} already published`); + skipped.push(label); + continue; + } + + try { + publishOne(pkg); + console.log(` โœ… ${label}`); + published.push(label); + } catch { + // Double-check: the publish might have actually succeeded + // (e.g. npm returned non-zero but the package landed) + if (!isDryRun && isPublished(pkg.name, pkg.version)) { + console.log(` โญ๏ธ ${label} already published (confirmed after error)`); + skipped.push(label); + } else { + console.error(` โŒ ${label} failed, will retry`); + toRetry.push(pkg); + } + } +} + +// Retry failed packages once +const finalFailed = []; +if (toRetry.length > 0 && !isDryRun) { + console.log(`\n๐Ÿ”„ Retrying ${toRetry.length} failed package(s)...`); + + for (const pkg of toRetry) { + const label = `${pkg.name}@${pkg.version}`; + + if (isPublished(pkg.name, pkg.version)) { + console.log(` โญ๏ธ ${label} now published`); + skipped.push(label); + continue; + } + + try { + publishOne(pkg); + console.log(` โœ… ${label} (retry)`); + published.push(label); + } catch { + if (isPublished(pkg.name, pkg.version)) { + console.log(` โญ๏ธ ${label} now published (confirmed after retry error)`); + skipped.push(label); + } else { + console.error(` โŒ ${label} retry failed`); + finalFailed.push(label); + } + } + } +} + +// Summary +console.log('\n๐Ÿ“Š Publish Summary:'); +console.log(` Published: ${published.length}`); +console.log(` Skipped: ${skipped.length}`); +console.log(` Failed: ${finalFailed.length}`); + +if (finalFailed.length > 0) { + console.error('\nโŒ Failed packages:'); + for (const label of finalFailed) { + console.error(` - ${label}`); + } + process.exit(1); +} + +console.log('\nโœ… All packages published successfully!');