mirror of
https://github.com/less/less.js.git
synced 2026-05-01 03:00:22 -04:00
* Initial plan * fix: correct release automation for master merges and publishing - create-release-pr.yml: add set -euo pipefail; track whether a commit was created; skip push + gh pr create when no version changes (no-op safety - fixes the "no commits between head and base" failure). - scripts/bump-and-publish.js: on master, use the version already in package.json as-is (no auto-increment). Validate it is > NPM version. Skip updateAllVersions/git-add/git-commit on master so the published tag always points to the release PR merge commit on master, not to a local detached commit. Alpha behavior is unchanged. - Fix error message: on master say "Git tag was pushed" rather than "Version bump commit and tag were pushed". Co-authored-by: matthew-dean <414752+matthew-dean@users.noreply.github.com> * test: add release automation test suite (20 tests) Proves the three components of the release flow work correctly: - publish.yml if: conditions (6 scenarios) - create-release-pr.yml if: conditions (4 scenarios) - bump-and-publish.js master path: existing version, no commit, no push (4 tests) - bump-and-publish.js alpha path: auto-increment, commit, alpha tag (4 tests) - create-release-pr no-op safety: commit when needed, clean exit when not (2 tests) Run with: node scripts/test-release-automation.js or: npm run test:release (after pnpm install) Co-authored-by: matthew-dean <414752+matthew-dean@users.noreply.github.com> * plan: implement PR-based release flow for alpha branch Co-authored-by: matthew-dean <414752+matthew-dean@users.noreply.github.com> * feat: PR-based release flow for alpha branch (mirrors master) - create-release-pr.yml: listen on alpha push; compute alpha version increment (X.Y.Z-alpha.N → X.Y.Z-alpha.N+1); use branch-specific PR title/base/branch naming; update loop guards for both flavours - publish.yml: remove push:alpha trigger; add alpha to pull_request branches; update if: condition for alpha release PR title+base - bump-and-publish.js: remove auto-increment/commit/push for alpha; add getNpmAlphaVersion(); alpha now validates and publishes like master - test-release-automation.js: 34 tests covering new flows end-to-end Co-authored-by: matthew-dean <414752+matthew-dean@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: matthew-dean <414752+matthew-dean@users.noreply.github.com>
419 lines
15 KiB
JavaScript
Executable File
419 lines
15 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Version bumping and publishing script for Less.js monorepo
|
|
*
|
|
* This script:
|
|
* 1. Determines the next version (patch increment or explicit)
|
|
* 2. Updates all package.json files to the same version
|
|
* 3. Creates and pushes an annotated git tag
|
|
* 4. Publishes all packages to NPM
|
|
*
|
|
* Both master and alpha now use a PR-based release flow:
|
|
*
|
|
* master → "chore: release vX.Y.Z" PR created by create-release-pr.yml
|
|
* alpha → "chore: alpha release vX.Y.Z" PR created by create-release-pr.yml
|
|
*
|
|
* Merging the release PR lands the version-bump commit on the branch and
|
|
* triggers this script. At that point package.json already carries the
|
|
* target version. This script validates it, creates an annotated tag, pushes
|
|
* the tag, and publishes to npm. No local commit or branch push is made here.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { execSync } = require('child_process');
|
|
const semver = require('semver');
|
|
|
|
const ROOT_DIR = path.resolve(__dirname, '..');
|
|
const PACKAGES_DIR = path.join(ROOT_DIR, 'packages');
|
|
|
|
// Get all package.json files
|
|
function getPackageFiles() {
|
|
const packages = [];
|
|
|
|
// Root package.json
|
|
const rootPkgPath = path.join(ROOT_DIR, 'package.json');
|
|
if (fs.existsSync(rootPkgPath)) {
|
|
packages.push(rootPkgPath);
|
|
}
|
|
|
|
// Package directories
|
|
const packageDirs = fs.readdirSync(PACKAGES_DIR, { withFileTypes: true })
|
|
.filter(dirent => dirent.isDirectory())
|
|
.map(dirent => path.join(PACKAGES_DIR, dirent.name));
|
|
|
|
for (const pkgDir of packageDirs) {
|
|
const pkgPath = path.join(pkgDir, 'package.json');
|
|
if (fs.existsSync(pkgPath)) {
|
|
packages.push(pkgPath);
|
|
}
|
|
}
|
|
|
|
return packages;
|
|
}
|
|
|
|
// Read package.json
|
|
function readPackage(pkgPath) {
|
|
return JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
}
|
|
|
|
// Write package.json
|
|
function writePackage(pkgPath, pkg) {
|
|
const content = JSON.stringify(pkg, null, '\t') + '\n';
|
|
fs.writeFileSync(pkgPath, content, 'utf8');
|
|
}
|
|
|
|
// Parse version string
|
|
function parseVersion(version) {
|
|
const parts = version.split('.');
|
|
return {
|
|
major: parseInt(parts[0], 10),
|
|
minor: parseInt(parts[1], 10),
|
|
patch: parseInt(parts[2], 10),
|
|
prerelease: parts[3] || null
|
|
};
|
|
}
|
|
|
|
// Get current version from main package
|
|
function getCurrentVersion() {
|
|
const lessPkgPath = path.join(PACKAGES_DIR, 'less', 'package.json');
|
|
const pkg = readPackage(lessPkgPath);
|
|
return pkg.version;
|
|
}
|
|
|
|
// Get the latest published version from NPM
|
|
function getNpmVersion(packageName) {
|
|
try {
|
|
return execSync(`npm view ${packageName} version`, { encoding: 'utf8' }).trim();
|
|
} catch (e) {
|
|
// Package not yet published
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Get the current alpha dist-tag version from NPM
|
|
function getNpmAlphaVersion(packageName) {
|
|
try {
|
|
const result = execSync(`npm view ${packageName} dist-tags.alpha`, { encoding: 'utf8' }).trim();
|
|
return result || null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Determine the target version for publishing.
|
|
// Priority: EXPLICIT_VERSION env > package.json (if ahead of NPM) > NPM patch bump
|
|
function getTargetVersion(currentVersion, npmVersion) {
|
|
// 1. Explicit override via environment variable
|
|
if (process.env.EXPLICIT_VERSION) {
|
|
console.log(`✨ Using explicit version from env: ${process.env.EXPLICIT_VERSION}`);
|
|
return process.env.EXPLICIT_VERSION;
|
|
}
|
|
|
|
// 2. If package.json is ahead of NPM, use it
|
|
if (npmVersion && semver.valid(currentVersion) && semver.gt(currentVersion, npmVersion)) {
|
|
console.log(`📦 package.json (${currentVersion}) is ahead of NPM (${npmVersion}), using it`);
|
|
return currentVersion;
|
|
}
|
|
|
|
// 3. Otherwise, bump from the latest NPM version
|
|
const base = npmVersion || currentVersion;
|
|
const next = semver.inc(base, 'patch');
|
|
console.log(`🔢 Auto-incrementing patch: ${base} → ${next}`);
|
|
return next;
|
|
}
|
|
|
|
// Update all package.json files with new version
|
|
function updateAllVersions(newVersion) {
|
|
const packageFiles = getPackageFiles();
|
|
const updated = [];
|
|
|
|
for (const pkgPath of packageFiles) {
|
|
const pkg = readPackage(pkgPath);
|
|
if (pkg.version) {
|
|
pkg.version = newVersion;
|
|
writePackage(pkgPath, pkg);
|
|
updated.push(pkgPath);
|
|
}
|
|
}
|
|
|
|
return updated;
|
|
}
|
|
|
|
// Get packages that should be published (not private)
|
|
function getPublishablePackages() {
|
|
const packageFiles = getPackageFiles();
|
|
const publishable = [];
|
|
|
|
for (const pkgPath of packageFiles) {
|
|
const pkg = readPackage(pkgPath);
|
|
// Skip root package and private packages
|
|
if (!pkg.private && pkg.name && pkg.name !== '@less/root') {
|
|
publishable.push({
|
|
path: pkgPath,
|
|
name: pkg.name,
|
|
dir: path.dirname(pkgPath)
|
|
});
|
|
}
|
|
}
|
|
|
|
return publishable;
|
|
}
|
|
|
|
// Main function
|
|
function main() {
|
|
const dryRun = process.env.DRY_RUN === 'true' || process.argv.includes('--dry-run');
|
|
const branch = process.env.GITHUB_REF_NAME || execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
|
|
const isAlpha = branch === 'alpha';
|
|
const isMaster = branch === 'master';
|
|
|
|
if (dryRun) {
|
|
console.log(`🧪 DRY RUN MODE - No changes will be committed or published\n`);
|
|
}
|
|
|
|
// Enforce branch restrictions - only allow publishing from master or alpha branches
|
|
if (!isMaster && !isAlpha) {
|
|
console.error(`❌ ERROR: Publishing is only allowed from 'master' or 'alpha' branches`);
|
|
console.error(` Current branch: ${branch}`);
|
|
console.error(` Please switch to 'master' or 'alpha' branch before publishing`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`🚀 Starting publish process for branch: ${branch}`);
|
|
|
|
// Get current version
|
|
const currentVersion = getCurrentVersion();
|
|
console.log(`📦 Current version: ${currentVersion}`);
|
|
|
|
// Determine next version.
|
|
// Both master and alpha now use the PR-based release flow: the version bump
|
|
// was already applied by the release PR. Use the version in package.json
|
|
// as-is and fail fast if it is not ahead of the already-published version.
|
|
let nextVersion;
|
|
|
|
if (isAlpha) {
|
|
// Validate that the version carries the expected '-alpha.' prerelease tag.
|
|
if (!currentVersion.includes('-alpha.')) {
|
|
console.error(`❌ ERROR: Alpha branch package.json version (${currentVersion}) must contain '-alpha.'`);
|
|
console.error(` The alpha release PR should have bumped to an X.Y.Z-alpha.N version.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const npmAlphaVersion = getNpmAlphaVersion('less');
|
|
console.log(`📦 NPM alpha version: ${npmAlphaVersion || '(not published)'}`);
|
|
if (npmAlphaVersion && semver.valid(currentVersion) && !semver.gt(currentVersion, npmAlphaVersion)) {
|
|
console.error(`❌ ERROR: package.json version (${currentVersion}) must be greater than NPM alpha version (${npmAlphaVersion})`);
|
|
console.error(` On alpha the version bump should have arrived via the alpha release PR.`);
|
|
process.exit(1);
|
|
}
|
|
nextVersion = currentVersion;
|
|
console.log(`📦 Using package.json version (no auto-increment on alpha): ${nextVersion}`);
|
|
} else {
|
|
// For master: the version bump was already applied via the release PR.
|
|
// Use the version already in package.json as-is; never auto-increment here
|
|
// because that would create a local commit whose tag would point to a
|
|
// commit that is NOT on the master branch.
|
|
const npmVersion = getNpmVersion('less');
|
|
console.log(`📦 NPM version: ${npmVersion || '(not published)'}`);
|
|
if (npmVersion && semver.valid(currentVersion) && !semver.gt(currentVersion, npmVersion)) {
|
|
console.error(`❌ ERROR: package.json version (${currentVersion}) must be greater than NPM version (${npmVersion})`);
|
|
console.error(` On master the version bump should have arrived via the release PR.`);
|
|
process.exit(1);
|
|
}
|
|
nextVersion = currentVersion;
|
|
console.log(`📦 Using package.json version (no auto-increment on master): ${nextVersion}`);
|
|
}
|
|
|
|
// Get publishable packages
|
|
const publishable = getPublishablePackages();
|
|
console.log(`📦 Found ${publishable.length} publishable packages:`);
|
|
publishable.forEach(pkg => console.log(` - ${pkg.name}`));
|
|
|
|
// Both master and alpha: the version-bump commit already lives on the branch
|
|
// (it came from the release PR). Do NOT create another local commit or push
|
|
// to the branch — doing so would produce a tag pointing at a commit that is
|
|
// not on the target branch.
|
|
//
|
|
// Only the annotated tag is pushed. Tag pushes bypass branch-protection
|
|
// "require pull request" rules.
|
|
|
|
// Create tag
|
|
const tagName = `v${nextVersion}`;
|
|
console.log(`🏷️ Creating git tag: ${tagName}...`);
|
|
if (!dryRun) {
|
|
try {
|
|
execSync(`git tag -a "${tagName}" -m "Release ${tagName}"`, {
|
|
cwd: ROOT_DIR,
|
|
stdio: 'inherit'
|
|
});
|
|
} catch (e) {
|
|
console.log(`⚠️ Tag might already exist, continuing...`);
|
|
}
|
|
} else {
|
|
console.log(` [DRY RUN] Would create tag: ${tagName}`);
|
|
}
|
|
|
|
// For master the version-bump commit already lives in master (it came from
|
|
// the release PR). Only push the git tag — tag pushes bypass branch
|
|
// protection "require pull request" rules.
|
|
// Alpha follows the same pattern: the version bump arrived via the alpha
|
|
// release PR, so we only push the tag here too. console.log(`📤 Pushing tag ${tagName}...`);
|
|
if (!dryRun) {
|
|
execSync(`git push origin "${tagName}"`, { cwd: ROOT_DIR, stdio: 'inherit' });
|
|
} else {
|
|
console.log(` [DRY RUN] Would push tag: origin ${tagName}`);
|
|
}
|
|
|
|
// Validate alpha branch requirements
|
|
if (isAlpha) {
|
|
console.log(`\n🔍 Validating alpha branch requirements...`);
|
|
|
|
// Validation 1: Version must contain 'alpha'
|
|
if (!nextVersion.includes('-alpha.')) {
|
|
console.error(`❌ ERROR: Alpha branch version must contain '-alpha.'`);
|
|
console.error(` Generated version: ${nextVersion}`);
|
|
console.error(` Expected format: X.Y.Z-alpha.N`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`✅ Version contains 'alpha' suffix: ${nextVersion}`);
|
|
|
|
// Validation 2: Must publish with 'alpha' tag
|
|
// (This is enforced in the code below, but we log it for clarity)
|
|
console.log(`✅ Will publish with 'alpha' tag (enforced)`);
|
|
|
|
// Validation 3: Check if alpha is behind master
|
|
try {
|
|
execSync('git fetch origin master:master 2>/dev/null || true', { cwd: ROOT_DIR });
|
|
const masterCommits = execSync('git rev-list --count alpha..master 2>/dev/null || echo "0"', {
|
|
cwd: ROOT_DIR,
|
|
encoding: 'utf8'
|
|
}).trim();
|
|
|
|
if (parseInt(masterCommits, 10) > 0) {
|
|
console.error(`❌ ERROR: Alpha branch is behind master by ${masterCommits} commit(s)`);
|
|
console.error(` Alpha branch must include all commits from master before publishing`);
|
|
console.error(` Please merge master into alpha first`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`✅ Alpha branch is up to date with master`);
|
|
} catch (e) {
|
|
console.log(`⚠️ Could not verify master sync status, continuing...`);
|
|
}
|
|
|
|
// Validation 4: Alpha base version must be >= master version
|
|
try {
|
|
const masterVersionStr = execSync('git show master:packages/less/package.json 2>/dev/null', {
|
|
cwd: ROOT_DIR,
|
|
encoding: 'utf8'
|
|
});
|
|
const masterPkg = JSON.parse(masterVersionStr);
|
|
const masterVersion = masterPkg.version;
|
|
|
|
// Extract base version from alpha version (remove -alpha.X)
|
|
const alphaBase = nextVersion.replace(/-alpha\.\d+$/, '');
|
|
|
|
// Semver comparison using semver library
|
|
const isGreaterOrEqual = semver.gte(alphaBase, masterVersion);
|
|
|
|
if (!isGreaterOrEqual) {
|
|
console.error(`❌ ERROR: Alpha base version (${alphaBase}) is lower than master version (${masterVersion})`);
|
|
console.error(` According to semver, alpha base version must be >= master version`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`✅ Alpha base version (${alphaBase}) is >= master version (${masterVersion})`);
|
|
} catch (e) {
|
|
console.log(`⚠️ Could not compare with master version, continuing...`);
|
|
}
|
|
}
|
|
|
|
// Determine NPM tag based on branch and version
|
|
const npmTag = isAlpha ? 'alpha' : 'latest';
|
|
const isAlphaVersion = nextVersion.includes('-alpha.');
|
|
|
|
// Validation: Alpha versions must use 'alpha' tag, non-alpha versions must use 'latest' tag
|
|
if (isAlphaVersion && npmTag !== 'alpha') {
|
|
console.error(`❌ ERROR: Alpha version (${nextVersion}) must be published with 'alpha' tag, not '${npmTag}'`);
|
|
console.error(` Alpha versions cannot be published to 'latest' tag`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!isAlphaVersion && npmTag === 'alpha') {
|
|
console.error(`❌ ERROR: Non-alpha version (${nextVersion}) cannot be published with 'alpha' tag`);
|
|
console.error(` Only versions containing '-alpha.' can be published to 'alpha' tag`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Enforce alpha tag for alpha branch
|
|
if (isAlpha && npmTag !== 'alpha') {
|
|
console.error(`❌ ERROR: Alpha branch must publish with 'alpha' tag, not '${npmTag}'`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`\n📦 Publishing packages to NPM with tag: ${npmTag}...`);
|
|
|
|
const publishErrors = [];
|
|
|
|
for (const pkg of publishable) {
|
|
console.log(`\n📤 Publishing ${pkg.name}...`);
|
|
if (dryRun) {
|
|
console.log(` [DRY RUN] Would publish: ${pkg.name}@${nextVersion} with tag: ${npmTag}`);
|
|
console.log(` [DRY RUN] Command: npm publish --tag ${npmTag}`);
|
|
} else {
|
|
try {
|
|
// For scoped packages, ensure access is set correctly
|
|
const publishCmd = `npm publish --tag ${npmTag} --access public`;
|
|
execSync(publishCmd, {
|
|
cwd: pkg.dir,
|
|
stdio: 'inherit',
|
|
env: { ...process.env, NODE_AUTH_TOKEN: process.env.NPM_TOKEN }
|
|
});
|
|
console.log(`✅ Successfully published ${pkg.name}@${nextVersion}`);
|
|
} catch (e) {
|
|
const errorMsg = e.message || String(e);
|
|
console.error(`❌ Failed to publish ${pkg.name}: ${errorMsg}`);
|
|
publishErrors.push({ name: pkg.name, error: errorMsg });
|
|
// Continue with other packages instead of exiting immediately
|
|
}
|
|
}
|
|
}
|
|
|
|
// Report any publish errors at the end
|
|
if (publishErrors.length > 0) {
|
|
console.error(`\n❌ Publishing completed with ${publishErrors.length} error(s):`);
|
|
publishErrors.forEach(({ name, error }) => {
|
|
console.error(` - ${name}: ${error}`);
|
|
});
|
|
console.error(`\n⚠️ Note: Git tag was pushed successfully.`);
|
|
console.error(` Some packages failed to publish. You may need to publish them manually.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (dryRun) {
|
|
console.log(`\n🧪 DRY RUN COMPLETE - No changes were made`);
|
|
console.log(` Would publish version: ${nextVersion}`);
|
|
console.log(` Would create tag: ${tagName}`);
|
|
console.log(` Would use NPM tag: ${npmTag}`);
|
|
} else {
|
|
console.log(`\n🎉 Successfully published all packages!`);
|
|
console.log(` Version: ${nextVersion}`);
|
|
console.log(` Tag: ${tagName}`);
|
|
console.log(` NPM Tag: ${npmTag}`);
|
|
}
|
|
|
|
// Output version for GitHub Actions
|
|
if (process.env.GITHUB_OUTPUT) {
|
|
fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${nextVersion}\n`);
|
|
fs.appendFileSync(process.env.GITHUB_OUTPUT, `tag=${tagName}\n`);
|
|
}
|
|
|
|
return { version: nextVersion, tag: tagName };
|
|
}
|
|
|
|
// Run if called directly
|
|
if (require.main === module) {
|
|
main();
|
|
}
|
|
|
|
module.exports = { main };
|