diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..e1cfc5e63d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,370 @@ +name: Release Automation + +on: + workflow_dispatch: + inputs: + release_type: + description: 'Release type' + required: true + type: choice + options: + - minor + - patch + - rc + - final + base_minor: + description: 'Base minor version (e.g., "25.15") - required for minor/patch/rc/final' + required: false + type: string + force_version: + description: 'Force specific version (optional, overrides automatic planning)' + required: false + type: string + auto_approve_notes: + description: 'Auto-approve release notes without manual review' + required: false + type: boolean + default: false + dry_run: + description: 'Dry run - plan release without making changes' + required: false + type: boolean + default: false + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +jobs: + plan: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.plan.outputs.version }} + tag: ${{ steps.plan.outputs.tag }} + branch: ${{ steps.plan.outputs.branch }} + needs_new_branch: ${{ steps.plan.outputs.needs_new_branch }} + is_prerelease: ${{ steps.plan.outputs.is_prerelease }} + release_type: ${{ steps.plan.outputs.release_type }} + previous_tag: ${{ steps.plan.outputs.previous_tag }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for tags + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: pnpm/action-setup@v4 + name: Install pnpm + with: + version: latest + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Plan release + id: plan + run: | + set -e + + echo "๐ŸŽฏ Planning release..." + echo "Release type: ${{ inputs.release_type }}" + echo "Base minor: ${{ inputs.base_minor }}" + echo "Force version: ${{ inputs.force_version }}" + echo "Dry run: ${{ inputs.dry_run }}" + + # Run the planning script + PLAN_OUTPUT=$(node scripts/release/plan.js "${{ inputs.release_type }}" "${{ inputs.base_minor }}" "${{ inputs.force_version }}") + echo "Plan output: $PLAN_OUTPUT" + + # Parse JSON output + VERSION=$(echo "$PLAN_OUTPUT" | jq -r '.version') + TAG=$(echo "$PLAN_OUTPUT" | jq -r '.tag') + BRANCH=$(echo "$PLAN_OUTPUT" | jq -r '.branch') + NEEDS_NEW_BRANCH=$(echo "$PLAN_OUTPUT" | jq -r '.needsNewBranch') + IS_PRERELEASE=$(echo "$PLAN_OUTPUT" | jq -r '.isPrerelease') + RELEASE_TYPE=$(echo "$PLAN_OUTPUT" | jq -r '.releaseType') + + # Find previous tag for release notes + PREVIOUS_TAG=$(git tag -l --sort=-version:refname | head -1 || echo "") + + echo "โœ… Release planned:" + echo " Version: $VERSION" + echo " Tag: $TAG" + echo " Branch: $BRANCH" + echo " Needs new branch: $NEEDS_NEW_BRANCH" + echo " Is prerelease: $IS_PRERELEASE" + echo " Release type: $RELEASE_TYPE" + echo " Previous tag: $PREVIOUS_TAG" + + # Set outputs + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "branch=$BRANCH" >> $GITHUB_OUTPUT + echo "needs_new_branch=$NEEDS_NEW_BRANCH" >> $GITHUB_OUTPUT + echo "is_prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT + echo "release_type=$RELEASE_TYPE" >> $GITHUB_OUTPUT + echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT + + - name: Validate plan + run: | + # Validate that we have all required information + if [ -z "${{ steps.plan.outputs.version }}" ]; then + echo "โŒ Version planning failed" + exit 1 + fi + + # Check if tag already exists + if git tag -l | grep -q "^${{ steps.plan.outputs.tag }}$"; then + echo "โŒ Tag ${{ steps.plan.outputs.tag }} already exists" + exit 1 + fi + + echo "โœ… Plan validation passed" + + generate-notes: + runs-on: ubuntu-latest + needs: plan + if: ${{ !inputs.dry_run }} + outputs: + release_notes: ${{ steps.notes.outputs.release_notes }} + notes_file: ${{ steps.notes.outputs.notes_file }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: pnpm/action-setup@v4 + name: Install pnpm + with: + version: latest + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Generate release notes + id: notes + run: | + set -e + + echo "๐Ÿ“ Generating release notes..." + echo "From tag: ${{ needs.plan.outputs.previous_tag }}" + echo "To ref: HEAD" + echo "Version: ${{ needs.plan.outputs.version }}" + + # Generate release notes + if [ -n "${{ needs.plan.outputs.previous_tag }}" ]; then + node scripts/release/generate-notes.js \ + "${{ needs.plan.outputs.previous_tag }}" \ + "HEAD" \ + "${{ needs.plan.outputs.version }}" + else + echo "# Release ${{ needs.plan.outputs.version }}" > /tmp/release-notes-${{ needs.plan.outputs.version }}.md + echo "" >> /tmp/release-notes-${{ needs.plan.outputs.version }}.md + echo "Initial release." >> /tmp/release-notes-${{ needs.plan.outputs.version }}.md + fi + + NOTES_FILE="/tmp/release-notes-${{ needs.plan.outputs.version }}.md" + + echo "โœ… Release notes generated" + echo "Notes file: $NOTES_FILE" + + # Set outputs + echo "notes_file=$NOTES_FILE" >> $GITHUB_OUTPUT + + # Read the file content for output (escape newlines) + RELEASE_NOTES=$(cat "$NOTES_FILE" | sed ':a;N;$!ba;s/\n/\\n/g') + echo "release_notes=$RELEASE_NOTES" >> $GITHUB_OUTPUT + + - name: Upload release notes + uses: actions/upload-artifact@v4 + with: + name: release-notes-${{ needs.plan.outputs.version }} + path: /tmp/release-notes-${{ needs.plan.outputs.version }}.md + retention-days: 30 + + approve-notes: + runs-on: ubuntu-latest + needs: [plan, generate-notes] + if: ${{ !inputs.dry_run && !inputs.auto_approve_notes }} + environment: release-approval + steps: + - name: Display release notes for approval + run: | + echo "๐Ÿ“‹ Please review the release notes for version ${{ needs.plan.outputs.version }}:" + echo "" + echo "${{ needs.generate-notes.outputs.release_notes }}" | sed 's/\\n/\n/g' + echo "" + echo "๐Ÿ” Release Plan Summary:" + echo " Version: ${{ needs.plan.outputs.version }}" + echo " Tag: ${{ needs.plan.outputs.tag }}" + echo " Branch: ${{ needs.plan.outputs.branch }}" + echo " Release Type: ${{ needs.plan.outputs.release_type }}" + echo " Is Prerelease: ${{ needs.plan.outputs.is_prerelease }}" + echo "" + echo "โœ… Approve this environment to proceed with the release" + + release: + runs-on: ubuntu-latest + needs: [plan, generate-notes, approve-notes] + if: ${{ !inputs.dry_run && (inputs.auto_approve_notes || success()) }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Create release branch + if: ${{ needs.plan.outputs.needs_new_branch == 'true' }} + run: | + echo "๐ŸŒฟ Creating new branch: ${{ needs.plan.outputs.branch }}" + git checkout -b "${{ needs.plan.outputs.branch }}" + git push origin "${{ needs.plan.outputs.branch }}" + + - name: Checkout release branch + if: ${{ needs.plan.outputs.needs_new_branch == 'false' }} + run: | + echo "๐Ÿ”„ Switching to existing branch: ${{ needs.plan.outputs.branch }}" + git fetch origin "${{ needs.plan.outputs.branch }}" || true + git checkout "${{ needs.plan.outputs.branch }}" || git checkout -b "${{ needs.plan.outputs.branch }}" + + - uses: pnpm/action-setup@v4 + name: Install pnpm + with: + version: latest + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + + - name: Update version + run: | + echo "๐Ÿ“ Updating version to ${{ needs.plan.outputs.version }}" + + # Update package.json using npm (works with pnpm too) + npm version "${{ needs.plan.outputs.version }}" --no-git-tag-version + + # Run make versiontag to update other files + make versiontag + + # Commit changes + git add . + git commit -m "chore: bump version to ${{ needs.plan.outputs.version }}" || echo "No changes to commit" + + - name: Create and push tag + run: | + echo "๐Ÿท๏ธ Creating tag: ${{ needs.plan.outputs.tag }}" + git tag -a "${{ needs.plan.outputs.tag }}" -m "Release ${{ needs.plan.outputs.version }}" + git push origin "${{ needs.plan.outputs.tag }}" + + # Push branch changes + git push origin "${{ needs.plan.outputs.branch }}" + + - name: Download release notes + uses: actions/download-artifact@v4 + with: + name: release-notes-${{ needs.plan.outputs.version }} + path: ./ + + - name: Create GitHub release + run: | + echo "๐Ÿš€ Creating GitHub release..." + + NOTES_FILE="release-notes-${{ needs.plan.outputs.version }}.md" + + gh release create "${{ needs.plan.outputs.tag }}" \ + --title "Release ${{ needs.plan.outputs.version }}" \ + --notes-file "$NOTES_FILE" \ + ${{ needs.plan.outputs.is_prerelease == 'true' && '--prerelease' || '' }} \ + --target "${{ needs.plan.outputs.branch }}" + + echo "โœ… Release created successfully" + + - name: Trigger package build + if: ${{ needs.plan.outputs.is_prerelease == 'false' }} + run: | + echo "๐Ÿ“ฆ Triggering package build workflow..." + # The package.yml workflow will be triggered by the release event + + notify: + runs-on: ubuntu-latest + needs: [plan, release] + if: ${{ !inputs.dry_run && always() }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + with: + version: latest + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Send Teams notification + if: ${{ vars.TEAMS_WEBHOOK_URL }} + run: | + if [ "${{ needs.release.result }}" = "success" ]; then + echo "โœ… Release ${{ needs.plan.outputs.version }} completed successfully!" + RELEASE_URL="https://github.com/lablup/backend.ai-webui/releases/tag/${{ needs.plan.outputs.tag }}" + + node scripts/release/teams-notify.js \ + "${{ vars.TEAMS_WEBHOOK_URL }}" \ + "${{ needs.plan.outputs.version }}" \ + "${{ needs.plan.outputs.tag }}" \ + "$RELEASE_URL" \ + "${{ needs.plan.outputs.is_prerelease }}" \ + "true" + else + echo "โŒ Release ${{ needs.plan.outputs.version }} failed!" + + node scripts/release/teams-notify.js \ + "${{ vars.TEAMS_WEBHOOK_URL }}" \ + "${{ needs.plan.outputs.version }}" \ + "${{ needs.plan.outputs.tag }}" \ + "" \ + "${{ needs.plan.outputs.is_prerelease }}" \ + "false" \ + "Release workflow failed. Check GitHub Actions for details." + fi + + - name: Log completion + run: | + if [ "${{ needs.release.result }}" = "success" ]; then + echo "โœ… Release ${{ needs.plan.outputs.version }} completed successfully!" + echo "๐Ÿ”— Release URL: https://github.com/lablup/backend.ai-webui/releases/tag/${{ needs.plan.outputs.tag }}" + else + echo "โŒ Release ${{ needs.plan.outputs.version }} failed!" + echo "๐Ÿ“‹ Check the workflow logs for details" + fi \ No newline at end of file diff --git a/scripts/release/README.md b/scripts/release/README.md new file mode 100644 index 0000000000..fd9ab62397 --- /dev/null +++ b/scripts/release/README.md @@ -0,0 +1,144 @@ +# Release Scripts + +This directory contains automation scripts for the Backend.AI WebUI release process. + +## Scripts + +### `utils.js` +Common utilities for release automation: +- Git operations (tags, branches, commits) +- Version parsing and comparison +- Package.json operations +- File system helpers + +### `plan.js` +Version planning and release type determination: +```bash +node plan.js [baseMinor] [forceVersion] +``` + +Examples: +```bash +# Plan minor release +node plan.js minor 25.16 + +# Plan patch release +node plan.js patch 25.15 + +# Plan RC release +node plan.js rc 25.16 + +# Plan final release (promote RC) +node plan.js final 25.16 + +# Force specific version +node plan.js minor 25.16 25.16.5 +``` + +### `generate-notes.js` +Release notes generation from git commits: +```bash +node generate-notes.js [toRef] [version] +``` + +Examples: +```bash +# Generate notes from latest tag to HEAD +node generate-notes.js v25.15.0 + +# Generate notes between specific refs +node generate-notes.js v25.15.0 HEAD 25.16.0 + +# Generate notes for specific version +node generate-notes.js v25.15.0 v25.16.0 25.16.0 +``` + +### `teams-notify.js` +Microsoft Teams notification sender: +```bash +node teams-notify.js [releaseUrl] [isPrerelease] [isSuccess] [error] +``` + +Example: +```bash +node teams-notify.js \ + "https://outlook.office.com/webhook/..." \ + "25.16.0" \ + "v25.16.0" \ + "https://github.com/lablup/backend.ai-webui/releases/tag/v25.16.0" \ + "false" \ + "true" +``` + +## Usage + +These scripts are primarily used by the GitHub Actions workflow (`.github/workflows/release.yml`), but can also be run manually for testing or troubleshooting. + +### Prerequisites + +- Node.js (version specified in `.nvmrc`) +- Git repository with proper remote setup +- GitHub CLI (`gh`) for some operations + +### Development + +To test scripts locally: + +```bash +# Install dependencies +npm install + +# Test version planning +node scripts/release/plan.js minor 25.16 + +# Test release notes generation +node scripts/release/generate-notes.js HEAD~10 HEAD 25.16.0 + +# Test with dry run +DRY_RUN=true node scripts/release/plan.js minor 25.16 +``` + +## Output Formats + +### Plan Output (JSON) +```json +{ + "version": "25.16.0", + "tag": "v25.16.0", + "branch": "25.16", + "needsNewBranch": true, + "isPrerelease": false, + "releaseType": "minor" +} +``` + +### Release Notes Output (Markdown) +```markdown +# Release 25.16.0 + +**Full Changelog**: https://github.com/lablup/backend.ai-webui/compare/v25.15.0...v25.16.0 + +## โœจ Features +- New feature description **FR-1234** by @author [#123](link) + +## ๐Ÿ› Bug Fixes +- Bug fix description **FR-1235** by @author [#124](link) +``` + +## Error Handling + +Scripts include comprehensive error handling and validation: +- Input parameter validation +- Git operation error handling +- Version format validation +- Conflict detection (existing tags, branches) +- Network error handling (Teams notifications) + +## Integration + +These scripts integrate with: +- GitHub Actions workflows +- Git repository operations +- GitHub API (via CLI) +- Microsoft Teams webhooks +- Backend.AI WebUI build system (`make` targets) \ No newline at end of file diff --git a/scripts/release/generate-notes.js b/scripts/release/generate-notes.js new file mode 100755 index 0000000000..df7cd04078 --- /dev/null +++ b/scripts/release/generate-notes.js @@ -0,0 +1,279 @@ +#!/usr/bin/env node + +const { execCommand } = require('./utils'); +const fs = require('fs'); +const path = require('path'); + +/** + * Generate release notes from git commits and PR information + */ + +/** + * Get commits between two tags/refs + * @param {string} fromRef - Starting reference (tag or commit) + * @param {string} toRef - Ending reference (tag or commit) + * @returns {Array} - Array of commit objects + */ +function getCommitsBetween(fromRef, toRef = 'HEAD') { + const command = `git log ${fromRef}..${toRef} --pretty=format:"%H|%s|%an|%ae|%ad" --date=short`; + + try { + const output = execCommand(command); + if (!output) return []; + + return output.split('\n').map(line => { + const [hash, subject, author, email, date] = line.split('|'); + return { hash, subject, author, email, date }; + }); + } catch (error) { + console.warn(`Failed to get commits between ${fromRef} and ${toRef}:`, error.message); + return []; + } +} + +/** + * Parse commit subject to extract PR number and type + * @param {string} subject - Commit subject line + * @returns {object} - Parsed commit information + */ +function parseCommitSubject(subject) { + // Match patterns like "feat(FR-1234): description (#1234)" + const prMatch = subject.match(/\(#(\d+)\)$/); + const prNumber = prMatch ? prMatch[1] : null; + + // Extract type (feat, fix, etc.) + const typeMatch = subject.match(/^(feat|fix|refactor|chore|docs|style|test|perf|build|ci|revert|hotfix|i18n|misc)(\([^)]+\))?: (.+)/); + let type = 'misc'; + let description = subject; + let scope = null; + + if (typeMatch) { + type = typeMatch[1]; + scope = typeMatch[2] ? typeMatch[2].slice(1, -1) : null; // Remove parentheses + description = typeMatch[3].replace(/\s*\(#\d+\)$/, ''); // Remove PR number from description + } + + // Extract FR number from scope or description + const frMatch = (scope || description).match(/FR-(\d+)/); + const frNumber = frMatch ? frMatch[1] : null; + + return { + type, + scope, + description, + prNumber, + frNumber, + original: subject + }; +} + +/** + * Categorize commits by type + * @param {Array} commits - Array of commit objects + * @returns {object} - Categorized commits + */ +function categorizeCommits(commits) { + const categories = { + 'feat': { title: 'โœจ Features', commits: [] }, + 'fix': { title: '๐Ÿ› Bug Fixes', commits: [] }, + 'refactor': { title: '๐Ÿ”จ Refactoring', commits: [] }, + 'chore': { title: '๐Ÿ›  Chores', commits: [] }, + 'i18n': { title: '๐ŸŒ i18n', commits: [] }, + 'test': { title: '๐Ÿงช E2E Tests', commits: [] }, + 'style': { title: '๐ŸŽจ Style', commits: [] }, + 'hotfix': { title: '๐Ÿš‘ Hotfix', commits: [] }, + 'docs': { title: '๐Ÿ“ Documentation', commits: [] }, + 'perf': { title: 'โšก Performance', commits: [] }, + 'misc': { title: '๐Ÿ”ง Miscellaneous', commits: [] } + }; + + const contributors = new Set(); + + commits.forEach(commit => { + const parsed = parseCommitSubject(commit.subject); + const category = categories[parsed.type] || categories['misc']; + + category.commits.push({ + ...commit, + ...parsed + }); + + contributors.add(commit.author); + }); + + // Filter out empty categories + const filteredCategories = Object.entries(categories) + .filter(([key, category]) => category.commits.length > 0) + .reduce((acc, [key, category]) => { + acc[key] = category; + return acc; + }, {}); + + return { + categories: filteredCategories, + contributors: Array.from(contributors) + }; +} + +/** + * Format commits into markdown release notes + * @param {object} categorizedCommits - Categorized commits object + * @param {object} options - Formatting options + * @returns {string} - Formatted markdown + */ +function formatReleaseNotes(categorizedCommits, options = {}) { + const { categories, contributors } = categorizedCommits; + const { version, previousVersion, includeContributors = true } = options; + + let markdown = ''; + + // Header + if (version) { + markdown += `# Release ${version}\n\n`; + } + + if (previousVersion) { + markdown += `**Full Changelog**: https://github.com/lablup/backend.ai-webui/compare/${previousVersion}...v${version}\n\n`; + } + + // Categories + Object.entries(categories).forEach(([type, category]) => { + markdown += `## ${category.title}\n\n`; + + category.commits.forEach(commit => { + let line = `- ${commit.description}`; + + // Add FR number if available + if (commit.frNumber) { + line += ` **FR-${commit.frNumber}**`; + } + + // Add author + line += ` by @${commit.author}`; + + // Add PR link if available + if (commit.prNumber) { + line += ` [#${commit.prNumber}](https://github.com/lablup/backend.ai-webui/pull/${commit.prNumber})`; + } + + markdown += `${line}\n`; + }); + + markdown += '\n'; + }); + + // Contributors section + if (includeContributors && contributors.length > 0) { + markdown += '## ๐Ÿ™Œ New Contributors\n\n'; + contributors.forEach(contributor => { + markdown += `- @${contributor}\n`; + }); + markdown += '\n'; + } + + return markdown.trim(); +} + +/** + * Generate release notes for a version + * @param {object} options - Generation options + * @returns {string} - Generated release notes + */ +function generateReleaseNotes({ fromTag, toRef = 'HEAD', version, includeContributors = true }) { + console.log(`๐ŸŽจ Generating release notes from ${fromTag} to ${toRef}...`); + + if (!fromTag) { + throw new Error('fromTag is required for release notes generation'); + } + + // Get commits between tags + const commits = getCommitsBetween(fromTag, toRef); + console.log(`Found ${commits.length} commits`); + + if (commits.length === 0) { + return `# Release ${version || 'Unknown'}\n\nNo changes in this release.`; + } + + // Categorize commits + const categorizedCommits = categorizeCommits(commits); + console.log(`Found ${Object.keys(categorizedCommits.categories).length} categories`); + console.log(`Contributors: ${categorizedCommits.contributors.length}`); + + // Format as markdown + const releaseNotes = formatReleaseNotes(categorizedCommits, { + version, + previousVersion: fromTag, + includeContributors + }); + + return releaseNotes; +} + +/** + * Find the previous tag for release notes generation + * @param {string} currentTag - Current tag to find previous for + * @returns {string|null} - Previous tag or null + */ +function findPreviousTag(currentTag) { + try { + const command = `git tag -l --sort=-version:refname`; + const allTags = execCommand(command).split('\n').filter(tag => tag.trim()); + + const currentIndex = allTags.indexOf(currentTag); + if (currentIndex > 0) { + return allTags[currentIndex + 1]; + } + + // If we can't find the current tag, get the latest tag + return allTags.length > 0 ? allTags[0] : null; + } catch (error) { + console.warn('Failed to find previous tag:', error.message); + return null; + } +} + +// CLI interface +if (require.main === module) { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.log('Usage: node generate-notes.js [toRef] [version]'); + console.log(' fromTag: Starting tag for changelog generation'); + console.log(' toRef: Ending reference (default: HEAD)'); + console.log(' version: Version for the release notes header'); + process.exit(1); + } + + try { + const [fromTag, toRef = 'HEAD', version] = args; + const releaseNotes = generateReleaseNotes({ + fromTag, + toRef, + version, + includeContributors: true + }); + + console.log('\n๐Ÿ“ Generated release notes:\n'); + console.log(releaseNotes); + + // Save to file if version is provided + if (version) { + const filename = `/tmp/release-notes-${version}.md`; + fs.writeFileSync(filename, releaseNotes); + console.log(`\n๐Ÿ’พ Saved to ${filename}`); + } + } catch (error) { + console.error('โŒ Release notes generation failed:'); + console.error(error.message); + process.exit(1); + } +} + +module.exports = { + generateReleaseNotes, + getCommitsBetween, + parseCommitSubject, + categorizeCommits, + formatReleaseNotes, + findPreviousTag +}; \ No newline at end of file diff --git a/scripts/release/plan.js b/scripts/release/plan.js new file mode 100755 index 0000000000..02e1b6d0dc --- /dev/null +++ b/scripts/release/plan.js @@ -0,0 +1,279 @@ +#!/usr/bin/env node + +const { + getAllTags, + getLatestTag, + parseVersion, + buildVersion, + getCurrentVersion, + compareVersions +} = require('./utils'); + +/** + * Version planning logic for release automation + * Determines the next version based on release type and existing tags + */ + +/** + * Plan the next version based on release type and parameters + * @param {object} options - Planning options + * @param {string} options.releaseType - Type of release: minor|patch|rc|final + * @param {string} [options.baseMinor] - Base minor version for minor/rc releases (e.g., "25.15") + * @param {string} [options.forceVersion] - Force a specific version + * @returns {object} - Release plan with version, branch, and metadata + */ +function plan({ releaseType, baseMinor, forceVersion }) { + console.log('๐ŸŽฏ Planning release...'); + console.log(`Release type: ${releaseType}`); + if (baseMinor) console.log(`Base minor: ${baseMinor}`); + if (forceVersion) console.log(`Force version: ${forceVersion}`); + + // If force version is specified, use it directly + if (forceVersion) { + try { + const parsedVersion = parseVersion(forceVersion); + const branchName = `${parsedVersion.major}.${parsedVersion.minor}`; + + return { + version: forceVersion, + tag: `v${forceVersion}`, + branch: branchName, + needsNewBranch: releaseType === 'minor', + isPrerelease: parsedVersion.prerelease !== null, + releaseType: 'forced' + }; + } catch (error) { + throw new Error(`Invalid force version format: ${forceVersion}`); + } + } + + const currentVersion = getCurrentVersion(); + const allTags = getAllTags(); + const latestTag = getLatestTag(); + + console.log(`Current package.json version: ${currentVersion}`); + console.log(`Latest git tag: ${latestTag || 'none'}`); + console.log(`Total tags found: ${allTags.length}`); + + switch (releaseType) { + case 'minor': + return planMinorRelease(baseMinor, allTags); + + case 'patch': + return planPatchRelease(baseMinor, allTags); + + case 'rc': + return planRcRelease(baseMinor, allTags); + + case 'final': + return planFinalRelease(baseMinor, allTags); + + default: + throw new Error(`Unknown release type: ${releaseType}`); + } +} + +/** + * Plan a minor release (e.g., 25.15.0) + */ +function planMinorRelease(baseMinor, allTags) { + if (!baseMinor) { + throw new Error('baseMinor is required for minor releases (e.g., "25.15")'); + } + + const [major, minor] = baseMinor.split('.').map(Number); + if (isNaN(major) || isNaN(minor)) { + throw new Error(`Invalid baseMinor format: ${baseMinor}. Expected format: "25.15"`); + } + + const version = `${major}.${minor}.0`; + const tag = `v${version}`; + const branchName = baseMinor; + + // Check if this version already exists + if (allTags.includes(tag)) { + throw new Error(`Version ${version} already exists as tag ${tag}`); + } + + return { + version, + tag, + branch: branchName, + needsNewBranch: true, + isPrerelease: false, + releaseType: 'minor' + }; +} + +/** + * Plan a patch release (increment patch version on existing branch) + */ +function planPatchRelease(baseMinor, allTags) { + if (!baseMinor) { + throw new Error('baseMinor is required for patch releases (e.g., "25.15")'); + } + + const [major, minor] = baseMinor.split('.').map(Number); + if (isNaN(major) || isNaN(minor)) { + throw new Error(`Invalid baseMinor format: ${baseMinor}. Expected format: "25.15"`); + } + + // Find the latest patch version for this minor version + const pattern = `v${major}\\.${minor}\\.\\d+$`; + const matchingTags = allTags.filter(tag => { + const regex = new RegExp(pattern); + return regex.test(tag) && !tag.includes('-'); // Exclude prereleases + }); + + let nextPatch = 0; + if (matchingTags.length > 0) { + const latestPatchTag = matchingTags.sort(compareVersions).pop(); + const latestVersion = parseVersion(latestPatchTag); + nextPatch = latestVersion.patch + 1; + } + + const version = `${major}.${minor}.${nextPatch}`; + const tag = `v${version}`; + const branchName = baseMinor; + + return { + version, + tag, + branch: branchName, + needsNewBranch: false, + isPrerelease: false, + releaseType: 'patch' + }; +} + +/** + * Plan an RC release + */ +function planRcRelease(baseMinor, allTags) { + if (!baseMinor) { + throw new Error('baseMinor is required for RC releases (e.g., "25.15")'); + } + + const [major, minor] = baseMinor.split('.').map(Number); + if (isNaN(major) || isNaN(minor)) { + throw new Error(`Invalid baseMinor format: ${baseMinor}. Expected format: "25.15"`); + } + + // Find existing RC versions for this minor version + const pattern = `v${major}\\.${minor}\\.\\d+-rc\\.\\d+$`; + const rcTags = allTags.filter(tag => { + const regex = new RegExp(pattern); + return regex.test(tag); + }); + + let rcNumber = 1; + let patchVersion = 0; + + if (rcTags.length > 0) { + const latestRcTag = rcTags.sort(compareVersions).pop(); + const latestRc = parseVersion(latestRcTag); + rcNumber = latestRc.prereleaseNumber + 1; + patchVersion = latestRc.patch; + } else { + // Check if there are any stable releases for this minor version + const stablePattern = `v${major}\\.${minor}\\.\\d+$`; + const stableTags = allTags.filter(tag => { + const regex = new RegExp(stablePattern); + return regex.test(tag); + }); + + if (stableTags.length > 0) { + const latestStableTag = stableTags.sort(compareVersions).pop(); + const latestStable = parseVersion(latestStableTag); + patchVersion = latestStable.patch + 1; + } + } + + const version = `${major}.${minor}.${patchVersion}-rc.${rcNumber}`; + const tag = `v${version}`; + const branchName = baseMinor; + + return { + version, + tag, + branch: branchName, + needsNewBranch: false, + isPrerelease: true, + releaseType: 'rc' + }; +} + +/** + * Plan a final release (promote RC to stable) + */ +function planFinalRelease(baseMinor, allTags) { + if (!baseMinor) { + throw new Error('baseMinor is required for final releases (e.g., "25.15")'); + } + + const [major, minor] = baseMinor.split('.').map(Number); + if (isNaN(major) || isNaN(minor)) { + throw new Error(`Invalid baseMinor format: ${baseMinor}. Expected format: "25.15"`); + } + + // Find the latest RC for this minor version + const rcPattern = `v${major}\\.${minor}\\.\\d+-rc\\.\\d+$`; + const rcTags = allTags.filter(tag => { + const regex = new RegExp(rcPattern); + return regex.test(tag); + }); + + if (rcTags.length === 0) { + throw new Error(`No RC versions found for ${baseMinor}. Create an RC first.`); + } + + const latestRcTag = rcTags.sort(compareVersions).pop(); + const latestRc = parseVersion(latestRcTag); + + // Remove prerelease suffix to create final version + const version = `${latestRc.major}.${latestRc.minor}.${latestRc.patch}`; + const tag = `v${version}`; + const branchName = baseMinor; + + // Check if final version already exists + if (allTags.includes(tag)) { + throw new Error(`Final version ${version} already exists as tag ${tag}`); + } + + return { + version, + tag, + branch: branchName, + needsNewBranch: false, + isPrerelease: false, + releaseType: 'final', + promotedFrom: latestRcTag + }; +} + +// CLI interface +if (require.main === module) { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.log('Usage: node plan.js [baseMinor] [forceVersion]'); + console.log(' releaseType: minor|patch|rc|final'); + console.log(' baseMinor: e.g., "25.15" (required for minor/patch/rc/final)'); + console.log(' forceVersion: e.g., "25.15.3" (optional)'); + process.exit(1); + } + + try { + const [releaseType, baseMinor, forceVersion] = args; + const result = plan({ releaseType, baseMinor, forceVersion }); + + console.log('\nโœ… Release plan:'); + console.log(JSON.stringify(result, null, 2)); + } catch (error) { + console.error('โŒ Planning failed:'); + console.error(error.message); + process.exit(1); + } +} + +module.exports = { plan }; \ No newline at end of file diff --git a/scripts/release/teams-notify.js b/scripts/release/teams-notify.js new file mode 100755 index 0000000000..9acf948fee --- /dev/null +++ b/scripts/release/teams-notify.js @@ -0,0 +1,228 @@ +#!/usr/bin/env node + +const https = require('https'); + +/** + * Send notification to Microsoft Teams channel + * @param {object} options - Notification options + * @returns {Promise} - Promise that resolves when notification is sent + */ +async function sendTeamsNotification({ + webhookUrl, + version, + tag, + releaseUrl, + isPrerelease = false, + isSuccess = true, + error = null +}) { + const payload = createTeamsPayload({ + version, + tag, + releaseUrl, + isPrerelease, + isSuccess, + error + }); + + return new Promise((resolve, reject) => { + const data = JSON.stringify(payload); + const url = new URL(webhookUrl); + + const options = { + hostname: url.hostname, + port: 443, + path: url.pathname + url.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': data.length + } + }; + + const req = https.request(options, (res) => { + let responseData = ''; + + res.on('data', (chunk) => { + responseData += chunk; + }); + + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + console.log('โœ… Teams notification sent successfully'); + resolve(responseData); + } else { + console.error(`โŒ Teams notification failed: ${res.statusCode}`); + reject(new Error(`HTTP ${res.statusCode}: ${responseData}`)); + } + }); + }); + + req.on('error', (error) => { + console.error('โŒ Teams notification error:', error.message); + reject(error); + }); + + req.write(data); + req.end(); + }); +} + +/** + * Create Teams message payload + * @param {object} options - Message options + * @returns {object} - Teams message payload + */ +function createTeamsPayload({ + version, + tag, + releaseUrl, + isPrerelease = false, + isSuccess = true, + error = null +}) { + const basePayload = { + "@type": "MessageCard", + "@context": "http://schema.org/extensions", + "themeColor": isSuccess ? "0076D7" : "FF0000", + "summary": `Backend.AI WebUI Release ${version}`, + "sections": [ + { + "activityTitle": `Backend.AI WebUI Release ${version}`, + "activitySubtitle": `Release ${isSuccess ? 'completed' : 'failed'}`, + "activityImage": "https://github.com/lablup/backend.ai-webui/raw/main/manifest/icon-512.png", + "facts": [ + { + "name": "Version", + "value": version + }, + { + "name": "Tag", + "value": tag + }, + { + "name": "Type", + "value": isPrerelease ? "Pre-release" : "Stable Release" + }, + { + "name": "Status", + "value": isSuccess ? "โœ… Success" : "โŒ Failed" + } + ], + "markdown": true + } + ] + }; + + if (isSuccess) { + // Add success-specific content + basePayload.sections[0].text = `๐Ÿš€ Release ${version} has been successfully created and published!`; + + if (releaseUrl) { + basePayload.potentialAction = [ + { + "@type": "OpenUri", + "name": "View Release", + "targets": [ + { + "os": "default", + "uri": releaseUrl + } + ] + }, + { + "@type": "OpenUri", + "name": "Download Assets", + "targets": [ + { + "os": "default", + "uri": `${releaseUrl}#assets` + } + ] + } + ]; + } + + // Add release notes section if it's a stable release + if (!isPrerelease) { + basePayload.sections.push({ + "activityTitle": "๐Ÿ“ฆ What's Next", + "text": "Desktop applications are being built and will be available shortly. Check the release page for download links.", + "markdown": true + }); + } + } else { + // Add failure-specific content + basePayload.sections[0].text = `โŒ Release ${version} failed to complete.`; + + if (error) { + basePayload.sections[0].facts.push({ + "name": "Error", + "value": error + }); + } + + basePayload.potentialAction = [ + { + "@type": "OpenUri", + "name": "View Workflow", + "targets": [ + { + "os": "default", + "uri": "https://github.com/lablup/backend.ai-webui/actions" + } + ] + } + ]; + } + + return basePayload; +} + +// CLI interface +if (require.main === module) { + const args = process.argv.slice(2); + + if (args.length < 3) { + console.log('Usage: node teams-notify.js [releaseUrl] [isPrerelease] [isSuccess] [error]'); + console.log(' webhookUrl: Teams webhook URL'); + console.log(' version: Release version (e.g., "25.15.0")'); + console.log(' tag: Release tag (e.g., "v25.15.0")'); + console.log(' releaseUrl: GitHub release URL (optional)'); + console.log(' isPrerelease: "true" or "false" (optional, default: false)'); + console.log(' isSuccess: "true" or "false" (optional, default: true)'); + console.log(' error: Error message (optional)'); + process.exit(1); + } + + const [ + webhookUrl, + version, + tag, + releaseUrl = null, + isPrerelease = 'false', + isSuccess = 'true', + error = null + ] = args; + + sendTeamsNotification({ + webhookUrl, + version, + tag, + releaseUrl, + isPrerelease: isPrerelease === 'true', + isSuccess: isSuccess === 'true', + error + }).then(() => { + console.log('Teams notification completed'); + process.exit(0); + }).catch((err) => { + console.error('Teams notification failed:', err.message); + process.exit(1); + }); +} + +module.exports = { + sendTeamsNotification, + createTeamsPayload +}; \ No newline at end of file diff --git a/scripts/release/utils.js b/scripts/release/utils.js new file mode 100755 index 0000000000..2a9b344fe0 --- /dev/null +++ b/scripts/release/utils.js @@ -0,0 +1,233 @@ +#!/usr/bin/env node + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +/** + * Utility functions for release automation + */ + +/** + * Execute a shell command and return the output + * @param {string} command - Command to execute + * @param {object} options - Options for child_process.execSync + * @returns {string} - Command output + */ +function execCommand(command, options = {}) { + try { + return execSync(command, { + encoding: 'utf8', + stdio: ['inherit', 'pipe', 'pipe'], + ...options + }).trim(); + } catch (error) { + console.error(`Command failed: ${command}`); + console.error(error.message); + throw error; + } +} + +/** + * Get all existing tags from the repository + * @returns {string[]} - Array of tag names + */ +function getAllTags() { + try { + const output = execCommand('git tag -l'); + return output ? output.split('\n').filter(tag => tag.trim()) : []; + } catch (error) { + console.warn('No tags found or git command failed'); + return []; + } +} + +/** + * Get the latest tag that matches a pattern + * @param {string} pattern - Pattern to match (e.g., 'v25.15.*') + * @returns {string|null} - Latest matching tag or null + */ +function getLatestTag(pattern = null) { + const tags = getAllTags(); + + if (!pattern) { + // Return the latest tag by version + return tags.length > 0 ? tags.sort(compareVersions).pop() : null; + } + + const matchingTags = tags.filter(tag => { + const regex = new RegExp(pattern.replace(/\*/g, '.*')); + return regex.test(tag); + }); + + return matchingTags.length > 0 ? matchingTags.sort(compareVersions).pop() : null; +} + +/** + * Compare two version strings (supports v prefix and PEP440-like versions) + * @param {string} a - First version + * @param {string} b - Second version + * @returns {number} - Comparison result (-1, 0, 1) + */ +function compareVersions(a, b) { + // Remove 'v' prefix if present + const cleanA = a.replace(/^v/, ''); + const cleanB = b.replace(/^v/, ''); + + // Split versions into parts + const partsA = cleanA.split(/[.-]/).map(part => { + const num = parseInt(part, 10); + return isNaN(num) ? part : num; + }); + const partsB = cleanB.split(/[.-]/).map(part => { + const num = parseInt(part, 10); + return isNaN(num) ? part : num; + }); + + const maxLength = Math.max(partsA.length, partsB.length); + + for (let i = 0; i < maxLength; i++) { + const partA = partsA[i] ?? 0; + const partB = partsB[i] ?? 0; + + if (typeof partA === 'number' && typeof partB === 'number') { + if (partA < partB) return -1; + if (partA > partB) return 1; + } else { + const strA = String(partA); + const strB = String(partB); + if (strA < strB) return -1; + if (strA > strB) return 1; + } + } + + return 0; +} + +/** + * Parse a version string into components + * @param {string} version - Version string (e.g., "25.15.2-rc.1") + * @returns {object} - Parsed version components + */ +function parseVersion(version) { + const cleanVersion = version.replace(/^v/, ''); + const match = cleanVersion.match(/^(\d+)\.(\d+)(?:\.(\d+))?(?:-?(alpha|beta|rc)(?:\.(\d+))?)?$/); + + if (!match) { + throw new Error(`Invalid version format: ${version}`); + } + + return { + major: parseInt(match[1], 10), + minor: parseInt(match[2], 10), + patch: parseInt(match[3] || '0', 10), + prerelease: match[4] || null, + prereleaseNumber: parseInt(match[5] || '0', 10), + original: version + }; +} + +/** + * Build a version string from components + * @param {object} components - Version components + * @returns {string} - Version string + */ +function buildVersion(components) { + let version = `${components.major}.${components.minor}.${components.patch}`; + + if (components.prerelease) { + version += `-${components.prerelease}`; + if (components.prereleaseNumber > 0) { + version += `.${components.prereleaseNumber}`; + } + } + + return version; +} + +/** + * Check if the current branch exists locally + * @param {string} branchName - Branch name to check + * @returns {boolean} - True if branch exists + */ +function branchExists(branchName) { + try { + execCommand(`git rev-parse --verify ${branchName}`); + return true; + } catch { + return false; + } +} + +/** + * Check if the current branch exists on remote + * @param {string} branchName - Branch name to check + * @param {string} remote - Remote name (default: 'origin') + * @returns {boolean} - True if branch exists on remote + */ +function remoteBranchExists(branchName, remote = 'origin') { + try { + execCommand(`git ls-remote --heads ${remote} ${branchName}`); + return true; + } catch { + return false; + } +} + +/** + * Get the current package.json version + * @returns {string} - Current version from package.json + */ +function getCurrentVersion() { + const packagePath = path.join(process.cwd(), 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + return packageJson.version; +} + +/** + * Update package.json version + * @param {string} newVersion - New version to set + */ +function updatePackageVersion(newVersion) { + const packagePath = path.join(process.cwd(), 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + packageJson.version = newVersion; + fs.writeFileSync(packagePath, JSON.stringify(packageJson, null, 2) + '\n'); +} + +/** + * Check if we're on the main branch + * @returns {boolean} - True if on main branch + */ +function isOnMainBranch() { + const currentBranch = execCommand('git branch --show-current'); + return currentBranch === 'main'; +} + +/** + * Check if working directory is clean + * @returns {boolean} - True if no uncommitted changes + */ +function isWorkingDirectoryClean() { + try { + const status = execCommand('git status --porcelain'); + return status.length === 0; + } catch { + return false; + } +} + +module.exports = { + execCommand, + getAllTags, + getLatestTag, + compareVersions, + parseVersion, + buildVersion, + branchExists, + remoteBranchExists, + getCurrentVersion, + updatePackageVersion, + isOnMainBranch, + isWorkingDirectoryClean +}; \ No newline at end of file