Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 85 additions & 31 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -113,52 +155,59 @@ 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 }}
script: |
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`;
}
}
Expand All @@ -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

Expand All @@ -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
149 changes: 149 additions & 0 deletions scripts/publish.js
Original file line number Diff line number Diff line change
@@ -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) {
Comment on lines +92 to +107
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Dry-run failures are currently swallowed and reported as success.

Failed dry-run publishes are only queued in toRetry, but retry is skipped in dry-run mode, so finalFailed stays empty and the script exits 0 with a success message.

💡 Suggested fix
 const published = [];
 const skipped = [];
 const toRetry = [];
+const finalFailed = [];

 for (const pkg of packages) {
   const label = `${pkg.name}@${pkg.version}`;
@@
   try {
     publishOne(pkg);
     console.log(`  ✅ ${label}`);
     published.push(label);
   } catch {
+    if (isDryRun) {
+      console.error(`  ❌ ${label} failed (dry-run)`);
+      finalFailed.push(label);
+      continue;
+    }
+
     // 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);
@@
-const finalFailed = [];
 if (toRetry.length > 0 && !isDryRun) {
@@
-console.log('\n✅ All packages published successfully!');
+console.log(
+  isDryRun
+    ? '\n✅ Dry-run publish checks completed successfully!'
+    : '\n✅ All packages published successfully!'
+);

Also applies to: 141-149

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/publish.js` around lines 92 - 107, The dry-run logic currently treats
caught publish errors as success because failures are only queued into toRetry
and retries are skipped when isDryRun is true, leaving finalFailed empty; fix by
treating dry-run publish errors as real failures: in the catch block (around the
isDryRun/isPublished check) ensure that when isDryRun is true you do not mark
the package as "already published" but instead record it as failed (add the
package/label to finalFailed or a dryRunFailed list and log an error), and
likewise in the retry section (where toRetry is handled when !isDryRun) add a
branch that when isDryRun is true moves all toRetry entries into finalFailed (or
logs them as failed) so the script exits non-zero or reports failures correctly;
reference the isDryRun, toRetry, finalFailed variables and the catch block
handling the publish result.

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!');
Loading