Release #35
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| on: | |
| push: | |
| branches: [main] | |
| workflow_dispatch: | |
| inputs: | |
| dist-tag: | |
| description: 'NPM dist-tag (leave empty to auto-generate from branch name, or specify: beta, next, etc.)' | |
| required: false | |
| default: '' | |
| dry-run: | |
| description: 'Dry run (validate without publishing)' | |
| required: false | |
| type: boolean | |
| default: false | |
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write # For creating tags and pushing commits | |
| issues: write # For creating GitHub releases | |
| pull-requests: write # For release notes | |
| id-token: write # For npm provenance generation | |
| jobs: | |
| release: | |
| name: Release Packages | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| environment: npm | |
| # Don't run on version bump commits to avoid infinite loop | |
| # Note: For push events on main, branch protection should require CI to pass before merge | |
| # For manual triggers, tests run inline below as a safety check | |
| if: | | |
| github.event_name == 'workflow_dispatch' || | |
| (github.event_name == 'push' && | |
| !startsWith(github.event.head_commit.message, 'chore(release):') && | |
| github.event.head_commit.author.username != 'github-actions[bot]') | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v6 | |
| with: | |
| # Fetch all history and tags for lerna to detect changes | |
| fetch-depth: 0 | |
| # Use a token that can trigger workflows | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Check workflow permissions | |
| if: github.event_name == 'workflow_dispatch' | |
| run: | | |
| echo "Checking permissions for manual workflow trigger..." | |
| # Get user's permission level | |
| PERMISSION=$(gh api \ | |
| repos/${{ github.repository }}/collaborators/${{ github.actor }}/permission \ | |
| --jq '.permission') | |
| echo "User: ${{ github.actor }}" | |
| echo "Permission: $PERMISSION" | |
| # Allow only maintain and admin permissions (not write, read, or triage) | |
| if [ "$PERMISSION" != "maintain" ] && [ "$PERMISSION" != "admin" ]; then | |
| echo "❌ Insufficient permissions to trigger manual pre-releases" | |
| echo " Required: maintain or admin access" | |
| echo " Current: $PERMISSION" | |
| exit 1 | |
| fi | |
| echo "✅ Permission check passed" | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Setup Node.js from .nvmrc | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version-file: '.nvmrc' | |
| cache: 'npm' | |
| registry-url: 'https://registry.npmjs.org' | |
| - name: Configure Git | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| - name: Detect release type | |
| id: release-type | |
| run: | | |
| BRANCH="${GITHUB_REF#refs/heads/}" | |
| echo "branch=$BRANCH" >> $GITHUB_OUTPUT | |
| # Check dry-run mode | |
| DRY_RUN="${{ github.event.inputs.dry-run }}" | |
| if [ "$DRY_RUN" == "true" ]; then | |
| echo "is_dry_run=true" >> $GITHUB_OUTPUT | |
| echo "🔍 DRY RUN MODE - No publishing will occur" | |
| else | |
| echo "is_dry_run=false" >> $GITHUB_OUTPUT | |
| fi | |
| # Determine dist-tag (empty input means auto-generate from branch) | |
| DIST_TAG_INPUT="${{ github.event.inputs.dist-tag }}" | |
| # Determine if this is a pre-release | |
| if [ "$BRANCH" != "main" ] || [ -n "$DIST_TAG_INPUT" ]; then | |
| echo "is_prerelease=true" >> $GITHUB_OUTPUT | |
| # Determine dist-tag | |
| if [ -n "$DIST_TAG_INPUT" ]; then | |
| DIST_TAG="$DIST_TAG_INPUT" | |
| # Validate dist-tag format | |
| if ! echo "$DIST_TAG" | grep -qE '^[a-zA-Z0-9][a-zA-Z0-9-]*$'; then | |
| echo "❌ Invalid dist-tag format: $DIST_TAG" | |
| echo " Must start with alphanumeric and contain only alphanumeric and hyphens" | |
| exit 1 | |
| fi | |
| # Check for reserved tags | |
| if [ "$DIST_TAG" == "latest" ] || [ "$DIST_TAG" == "next" ]; then | |
| echo "❌ Reserved dist-tag: $DIST_TAG" | |
| echo " Use empty value to trigger production release on main branch" | |
| exit 1 | |
| fi | |
| # Check length (npm allows up to 214 chars, but keep it reasonable) | |
| if [ ${#DIST_TAG} -gt 50 ]; then | |
| echo "❌ Dist-tag too long: ${#DIST_TAG} chars (max 50)" | |
| exit 1 | |
| fi | |
| else | |
| # Auto-generate dist-tag from branch name | |
| DIST_TAG=$(echo "$BRANCH" | sed 's/[^a-zA-Z0-9-]/-/g' | sed 's/^-*//' | sed 's/-*$//') | |
| # Ensure dist-tag is not empty after sanitization | |
| if [ -z "$DIST_TAG" ]; then | |
| echo "❌ Cannot generate valid dist-tag from branch name: $BRANCH" | |
| exit 1 | |
| fi | |
| fi | |
| echo "dist_tag=$DIST_TAG" >> $GITHUB_OUTPUT | |
| # Determine preid for versioning (remove trailing numeric suffix if present) | |
| PREID=$(echo "$DIST_TAG" | sed 's/-[0-9]*$//') | |
| echo "preid=$PREID" >> $GITHUB_OUTPUT | |
| echo "📦 Pre-release mode: dist-tag=$DIST_TAG, preid=$PREID" | |
| else | |
| echo "is_prerelease=false" >> $GITHUB_OUTPUT | |
| echo "dist_tag=latest" >> $GITHUB_OUTPUT | |
| echo "📦 Production release mode" | |
| fi | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Build packages | |
| run: npm run build | |
| - name: Run tests | |
| run: npm run test:ci | |
| - name: Version packages (production) | |
| if: steps.release-type.outputs.is_prerelease == 'false' | |
| id: version-production | |
| run: | | |
| # Capture current tags before versioning | |
| TAGS_BEFORE=$(git tag | wc -l) | |
| # Run lerna version (exits 0 even if no changes) | |
| npx lerna version --yes --no-private --sync-workspace-lock || EXIT_CODE=$? | |
| # Check if any new tags were created | |
| TAGS_AFTER=$(git tag | wc -l) | |
| TAGS_CREATED=$((TAGS_AFTER - TAGS_BEFORE)) | |
| if [ $TAGS_CREATED -eq 0 ]; then | |
| echo "has_changes=false" >> $GITHUB_OUTPUT | |
| echo "ℹ️ No packages to release - no changes detected" | |
| else | |
| echo "has_changes=true" >> $GITHUB_OUTPUT | |
| echo "✅ Created $TAGS_CREATED version tag(s)" | |
| fi | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Publish to npm (production) | |
| if: | | |
| steps.release-type.outputs.is_prerelease == 'false' && | |
| steps.release-type.outputs.is_dry_run == 'false' && | |
| steps.version-production.outputs.has_changes == 'true' | |
| run: npx lerna publish from-git --yes --no-private | |
| - name: No changes detected | |
| if: | | |
| steps.release-type.outputs.is_prerelease == 'false' && | |
| steps.version-production.outputs.has_changes == 'false' | |
| run: | | |
| echo "ℹ️ No packages have changes that warrant a release." | |
| echo "" | |
| echo "This can happen when:" | |
| echo " - Only ignored files changed (tests, docs, config)" | |
| echo " - Commits don't match conventional commit format" | |
| echo " - Recent release already published the changes" | |
| echo "" | |
| echo "See lerna.json ignoreChanges for patterns that skip releases." | |
| - name: Publish to npm (pre-release) | |
| if: steps.release-type.outputs.is_prerelease == 'true' && steps.release-type.outputs.is_dry_run == 'false' | |
| run: | | |
| # Use canary mode for automatic versioning with git SHA | |
| # Format: X.Y.Z-{preid}.0+{sha} (e.g., 0.6.1-pr123.0+abc1234) | |
| # The SHA ensures uniqueness even with multiple publishes | |
| # Provenance attestations are automatically generated via OIDC trusted publishing | |
| npx lerna publish --canary \ | |
| --preid ${{ steps.release-type.outputs.preid }} \ | |
| --dist-tag ${{ steps.release-type.outputs.dist_tag }} \ | |
| --yes \ | |
| --no-private \ | |
| --summary-file lerna-publish-summary.json | |
| - name: Verify packages were published | |
| if: steps.release-type.outputs.is_prerelease == 'true' && steps.release-type.outputs.is_dry_run == 'false' | |
| id: verify-publish | |
| run: | | |
| if [ -f lerna-publish-summary.json ]; then | |
| PUBLISHED_COUNT=$(jq '. | length' lerna-publish-summary.json 2>/dev/null || echo "0") | |
| if [ "$PUBLISHED_COUNT" -gt 0 ]; then | |
| echo "has_packages=true" >> $GITHUB_OUTPUT | |
| echo "published_count=$PUBLISHED_COUNT" >> $GITHUB_OUTPUT | |
| echo "✅ Published $PUBLISHED_COUNT package(s)" | |
| else | |
| echo "has_packages=false" >> $GITHUB_OUTPUT | |
| echo "published_count=0" >> $GITHUB_OUTPUT | |
| echo "ℹ️ No packages published - no changes detected" | |
| fi | |
| else | |
| echo "has_packages=false" >> $GITHUB_OUTPUT | |
| echo "published_count=0" >> $GITHUB_OUTPUT | |
| echo "⚠️ Lerna summary file not found - no packages published" | |
| fi | |
| - name: Find PR for this branch | |
| if: steps.release-type.outputs.is_prerelease == 'true' && steps.release-type.outputs.is_dry_run == 'false' | |
| id: find-pr | |
| run: | | |
| BRANCH="${{ steps.release-type.outputs.branch }}" | |
| PR_NUMBER=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number') | |
| if [ -n "$PR_NUMBER" ]; then | |
| echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT | |
| echo "✅ Found PR #$PR_NUMBER for branch $BRANCH" | |
| else | |
| echo "pr_number=" >> $GITHUB_OUTPUT | |
| echo "ℹ️ No open PR found for branch $BRANCH" | |
| fi | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Comment on PR with pre-release info | |
| if: steps.release-type.outputs.is_prerelease == 'true' && steps.release-type.outputs.is_dry_run == 'false' && steps.find-pr.outputs.pr_number != '' && steps.verify-publish.outputs.has_packages == 'true' | |
| run: | | |
| # Get list of actually published packages from lerna summary | |
| PACKAGES=$(jq -r '.[].packageName' lerna-publish-summary.json 2>/dev/null) | |
| # Validate that we have packages | |
| if [ -z "$PACKAGES" ]; then | |
| echo "❌ Error: No packages found in lerna summary" | |
| exit 1 | |
| fi | |
| # Build install commands for published packages | |
| INSTALL_COMMANDS="" | |
| for pkg in $PACKAGES; do | |
| INSTALL_COMMANDS="${INSTALL_COMMANDS}npm install ${pkg}@${{ steps.release-type.outputs.dist_tag }}\n" | |
| done | |
| # Build cleanup commands for published packages | |
| CLEANUP_COMMANDS="" | |
| for pkg in $PACKAGES; do | |
| CLEANUP_COMMANDS="${CLEANUP_COMMANDS}npm dist-tag rm ${pkg} ${{ steps.release-type.outputs.dist_tag }}\n" | |
| done | |
| gh pr comment ${{ steps.find-pr.outputs.pr_number }} --body "## 📦 Pre-release Published | |
| **Dist-tag**: \`${{ steps.release-type.outputs.dist_tag }}\` | |
| ### Installation | |
| Install any of the published packages using the dist-tag: | |
| \`\`\`bash | |
| ${INSTALL_COMMANDS}\`\`\` | |
| ### Manual Cleanup | |
| When you're done testing, you should manually remove the dist-tag to keep the registry clean: | |
| \`\`\`bash | |
| # Remove dist-tags (requires npm authentication): | |
| ${CLEANUP_COMMANDS}\`\`\` | |
| > **Note**: Cleanup is optional. Published versions remain available via exact version install (e.g., \`npm install @ya-modbus/cli@0.6.1-pr123.0+abc1234\`) even after the dist-tag is removed. | |
| <!-- ya-modbus-prerelease: ${{ steps.release-type.outputs.dist_tag }} -->" | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Comment on PR when no packages published | |
| if: steps.release-type.outputs.is_prerelease == 'true' && steps.release-type.outputs.is_dry_run == 'false' && steps.find-pr.outputs.pr_number != '' && steps.verify-publish.outputs.has_packages == 'false' | |
| run: | | |
| gh pr comment ${{ steps.find-pr.outputs.pr_number }} --body "## ℹ️ No Pre-release Published | |
| Pre-release workflow completed, but **no packages were published**. | |
| This typically happens when: | |
| - Only ignored files changed (tests, docs, config) | |
| - Commits don't match conventional commit format | |
| - No changes warrant a release per lerna.json ignoreChanges | |
| See \`lerna.json\` ignoreChanges for patterns that skip releases." | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Dry run summary | |
| if: steps.release-type.outputs.is_dry_run == 'true' | |
| run: | | |
| echo "## 🔍 Dry Run Summary" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "**No packages were published** - This was a validation run only." >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "### Configuration" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "- Branch: \`${{ steps.release-type.outputs.branch }}\`" >> $GITHUB_STEP_SUMMARY | |
| echo "- Pre-release: \`${{ steps.release-type.outputs.is_prerelease }}\`" >> $GITHUB_STEP_SUMMARY | |
| echo "- Dist-tag: \`${{ steps.release-type.outputs.dist_tag }}\`" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "To publish for real, re-run the workflow without the dry-run option." >> $GITHUB_STEP_SUMMARY | |
| - name: Pre-release summary | |
| if: steps.release-type.outputs.is_prerelease == 'true' && steps.release-type.outputs.is_dry_run == 'false' | |
| run: | | |
| if [ "${{ steps.verify-publish.outputs.has_packages }}" == "true" ]; then | |
| echo "## 📦 Pre-release Published" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "**Dist-tag**: \`${{ steps.release-type.outputs.dist_tag }}\`" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "**Packages published**: ${{ steps.verify-publish.outputs.published_count }}" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "### Installation" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "Install any of the published packages using the dist-tag:" >> $GITHUB_STEP_SUMMARY | |
| echo '```bash' >> $GITHUB_STEP_SUMMARY | |
| # Get list of actually published packages from lerna summary | |
| PACKAGES=$(jq -r '.[].packageName' lerna-publish-summary.json 2>/dev/null) | |
| for pkg in $PACKAGES; do | |
| echo "npm install ${pkg}@${{ steps.release-type.outputs.dist_tag }}" >> $GITHUB_STEP_SUMMARY | |
| done | |
| echo '```' >> $GITHUB_STEP_SUMMARY | |
| else | |
| echo "## ℹ️ No Pre-release Published" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "Pre-release workflow completed, but **no packages were published**." >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "This typically happens when:" >> $GITHUB_STEP_SUMMARY | |
| echo "- Only ignored files changed (tests, docs, config)" >> $GITHUB_STEP_SUMMARY | |
| echo "- Commits don't match conventional commit format" >> $GITHUB_STEP_SUMMARY | |
| echo "- No changes warrant a release per lerna.json ignoreChanges" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| - name: Production release summary | |
| if: steps.release-type.outputs.is_prerelease == 'false' && steps.release-type.outputs.is_dry_run == 'false' | |
| run: | | |
| echo "## 🚀 Production Release Published" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "Packages have been published to npm with the \`latest\` dist-tag." >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "See the [Releases](https://github.com/${{ github.repository }}/releases) page for details." >> $GITHUB_STEP_SUMMARY | |
| - name: Common summary | |
| if: steps.release-type.outputs.is_dry_run == 'false' | |
| run: | | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "---" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "**Workflow details:**" >> $GITHUB_STEP_SUMMARY | |
| echo "- Branch: \`${{ steps.release-type.outputs.branch }}\`" >> $GITHUB_STEP_SUMMARY | |
| echo "- Type: \`${{ steps.release-type.outputs.is_prerelease == 'true' && 'pre-release' || 'production' }}\`" >> $GITHUB_STEP_SUMMARY |