#!/usr/bin/env node import { execSync } from 'child_process'; import { writeFileSync, existsSync, mkdirSync } from 'fs'; const isCanary = process.argv[2] === 'canary'; const releaseType = isCanary ? 'canary' : 'production'; const emoji = isCanary ? '🐤' : '🚀'; const labelToChangeType = { breaking: 'major', 'type:feature': 'minor', 'type:bug': 'minor', 'type:hot fix': 'minor', 'type:technical debt': 'patch', 'type:security': 'patch', 'type:dependencies': 'patch', 'type:types': 'patch', 'type:testing': null, 'type:documentation': null, 'release:canary': 'patch', }; function logStep(message) { console.log(`${emoji} ${message}`); } function execCommand(command, description) { logStep(description); try { execSync(command, { stdio: 'inherit' }); return true; } catch (error) { console.error(`❌ Failed: ${description}`); console.error(error.message); return false; } } function getPackageNames() { try { const output = execSync('yarn workspaces list --json', { encoding: 'utf8' }); const workspaces = output .trim() .split('\n') .map((line) => JSON.parse(line)); return workspaces.filter((ws) => ws.location !== '.' && ws.name).map((ws) => ws.name); } catch (error) { console.warn('Could not get workspace packages:', error.message); return []; } } function generateChangesetYaml(packageNames, changeType) { if (packageNames.length === 0) return ''; return packageNames.map((name) => `"${name}": ${changeType}`).join('\n'); } function extractPublishedPackages(publishOutput, isCanary) { const publishLines = publishOutput.split('\n'); return publishLines .filter((line) => { return line.includes('@') && (isCanary ? line.includes('canary') : !line.includes('canary')); }) .map((line) => { // Extract package@version from changeset output lines const regex = isCanary ? /(@[^@]+@[\d\.-]+canary[\d-]+)/ : /(@[^@\s]+@[\d\.\-\w]+)/; const match = line.match(regex); return match ? match[1] : null; }) .filter(Boolean); } function getRepoInfo() { const repoUrl = process.env.CIRCLE_REPOSITORY_URL || 'https://github.com/jdalrymple/gitbeaker'; // Extract owner/repo from URL const match = repoUrl.match(/github\.com[/:]([\w-]+)\/([\w-]+)/); if (!match) { throw new Error(`Could not parse repository URL: ${repoUrl}`); } return { owner: match[1], repo: match[2], }; } async function githubApiRequest(endpoint, options = {}) { const { owner, repo } = getRepoInfo(); const url = `https://api.github.com/repos/${owner}/${repo}${endpoint}`; const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, Accept: 'application/vnd.github.v3+json', 'User-Agent': 'gitbeaker-api-client', 'Content-Type': 'application/json', ...options.headers, }, ...options, }); if (!response.ok) { throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); } return response.json(); } async function generateChangesetFromPR(prNumber, labels, prTitle) { if (!prNumber) { logStep('No PR number provided, skipping changeset generation'); return null; } logStep(`Generating changeset for PR #${prNumber} with labels: ${labels.join(', ')}`); // Find change type const changeType = labels .map((label) => labelToChangeType[label]) .filter(Boolean) .sort( (a, b) => ['major', 'minor', 'patch'].indexOf(a) - ['major', 'minor', 'patch'].indexOf(b), )[0]; if (!changeType) { logStep('No labels found that trigger a release, skipping changeset generation'); return null; } logStep(`Determined change type: ${changeType}`); // Get package names using yarn workspaces const packageNames = getPackageNames(); if (packageNames.length === 0) { console.warn('No packages found in yarn workspaces'); return null; } logStep(`Found packages: ${packageNames.join(', ')}`); // Create changeset directory if it doesn't exist if (!existsSync('.changeset')) { mkdirSync('.changeset'); } const changesetContent = `--- ${generateChangesetYaml(packageNames, changeType)} --- ${prTitle}`; const filename = `.changeset/pr-${prNumber}-${Date.now()}.md`; writeFileSync(filename, changesetContent); logStep(`Generated changeset: ${filename}`); return filename; } async function release() { logStep(`Starting ${releaseType} release`); const prNumber = process.env.PR_NUMBER; if (!prNumber) { logStep('No PR number found - skipping release'); return; } // Get PR data const prData = await githubApiRequest(`/pulls/${prNumber}`); const labels = prData.labels.map((label) => label.name); if (isCanary && !labels.includes('release:canary')) { logStep('No canary label present - skipping canary release'); return; } // Generate changesets (direct function call, no subprocess) logStep('Generating changeset from PR labels'); try { const changesetFile = await generateChangesetFromPR(prNumber, labels, prData.title); if (!changesetFile) { logStep(`No changeset generated - skipping ${releaseType} release`); return; } } catch (error) { console.error(`❌ Failed to generate changeset: ${error.message}`); process.exit(1); } // Version packages const versionCommand = isCanary ? 'yarn changeset version --snapshot canary' : 'yarn changeset version'; if (!execCommand(versionCommand, `Creating ${releaseType} versions`)) { process.exit(1); } // Update contributors (production only) if (!isCanary) { execCommand('yarn all-contributors-cli generate', 'Updating contributors (non-blocking)'); } // Publish packages const publishCommand = isCanary ? 'yarn changeset publish --tag canary --no-git-tag' : 'yarn changeset publish'; let publishedPackages = []; try { // Capture publish output to extract version info logStep(`Publishing ${releaseType} packages`); const publishOutput = execSync(publishCommand, { stdio: 'pipe', encoding: 'utf8' }); logStep(publishOutput); // Show the output to user publishedPackages = extractPublishedPackages(publishOutput, isCanary); } catch (error) { console.error(`❌ Failed to parse published packages: ${error.message}`); process.exit(1); } // Post PR comment for releases if (prNumber && publishedPackages.length > 0) { try { const releaseTitle = isCanary ? 'Canary Release Published' : 'Production Release Published'; const releaseDescription = isCanary ? 'canary versions' : 'new versions'; const installNote = isCanary ? 'Note: Canary releases are temporary and may be unstable. Use for testing purposes only.' : 'Note: These are production releases available on the `latest` tag.'; logStep(`Posting ${releaseType} release comment to PR`); const releaseLinks = publishedPackages .map((pkgVersion) => { // Handle scoped packages like @gitbeaker/cli@1.0.0 const lastAtIndex = pkgVersion.lastIndexOf('@'); const packageName = pkgVersion.substring(0, lastAtIndex); const version = pkgVersion.substring(lastAtIndex + 1); return `- [\`${packageName}@${version}\`](https://www.npmjs.com/package/${packageName}/v/${version})`; }) .join('\n'); const comment = `${emoji} **${releaseTitle}** ${emoji} The following packages have been published with ${releaseDescription}: ${releaseLinks} ${installNote}`; const commentType = isCanary ? 'canary' : 'production'; await githubApiRequest('/actions/workflows/post-release-comment.yml/dispatches', { method: 'POST', body: JSON.stringify({ ref: 'main', inputs: { pr_number: prNumber.toString(), comment_body: comment, comment_type: commentType, }, }), }); logStep(`Successfully triggered ${releaseType} release comment workflow`); } catch (error) { console.warn('Failed to post PR comment:', error.message); } } // Commit and push (production only) if (!isCanary) { const hasChanges = execCommand('git status --porcelain', { encoding: 'utf8' }).trim(); if (hasChanges) { if (!execCommand('git add .', 'Staging changes')) process.exit(1); if ( !execCommand( 'git commit -m "Version packages and update contributors"', 'Committing changes', ) ) process.exit(1); if (!execCommand('git push', 'Pushing changes')) process.exit(1); logStep('Successfully committed and pushed version changes'); } } logStep( `✅ ${releaseType.charAt(0).toUpperCase() + releaseType.slice(1)} release completed successfully!`, ); } release().catch((error) => { console.error( `❌ ${releaseType.charAt(0).toUpperCase() + releaseType.slice(1)} release failed:`, error, ); process.exit(1); });