Skip to content

Release

Release #35

Workflow file for this run

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