forked from less/less.js
-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathbump-and-publish.js
More file actions
executable file
Β·470 lines (408 loc) Β· 18.4 KB
/
Copy pathbump-and-publish.js
File metadata and controls
executable file
Β·470 lines (408 loc) Β· 18.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
#!/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 and push the annotated tag β idempotently.
//
// The tag is created and pushed BEFORE the npm publish loop below. If a
// previous run pushed the tag but then failed partway through publishing,
// the remote tag already exists. A naive rerun would die on `git tag` /
// `git push` for the existing tag before it ever reached the publish retry,
// leaving the release stuck until someone deletes the tag by hand.
//
// To make reruns safe we first check the remote for the tag (dereferenced to
// its commit): if it already marks this exact release commit we skip the tag
// step and fall straight through to publish; if it marks a DIFFERENT commit
// we abort rather than clobber; only when it is genuinely absent do we
// create + push it as before.
//
// For master the version-bump commit already lives on the branch (it came
// from the release PR). Only the annotated tag is pushed β tag pushes bypass
// branch-protection "require pull request" rules. Alpha follows the same
// pattern: the version bump arrived via the alpha release PR.
const tagName = `v${nextVersion}`;
const releaseCommit = execSync('git rev-parse HEAD', { cwd: ROOT_DIR, encoding: 'utf8' }).trim();
// Resolve the commit a remote tag points at. `^{}` dereferences an
// annotated tag to the commit it wraps; lightweight tags have no `^{}` line
// and the plain ref already IS the commit. Returns null when absent.
function getRemoteTagCommit(name) {
const out = execSync(`git ls-remote origin "refs/tags/${name}" "refs/tags/${name}^{}"`, {
cwd: ROOT_DIR,
encoding: 'utf8'
}).trim();
if (!out) return null;
const lines = out.split('\n').filter(Boolean);
// Prefer the dereferenced (annotated) commit line if present.
const derefLine = lines.find(l => l.endsWith(`refs/tags/${name}^{}`));
const plainLine = lines.find(l => l.endsWith(`refs/tags/${name}`));
const line = derefLine || plainLine;
return line ? line.split('\t')[0] : null;
}
console.log(`π·οΈ Preparing git tag: ${tagName} (release commit ${releaseCommit})...`);
const remoteTagCommit = getRemoteTagCommit(tagName);
if (remoteTagCommit && remoteTagCommit === releaseCommit) {
// Rerun-after-failed-publish path: the tag already marks this release.
console.log(`β
Remote tag ${tagName} already marks this release commit β skipping tag create/push, proceeding to publish.`);
} else if (remoteTagCommit) {
// Tag exists but points somewhere else β refuse to clobber.
console.error(`β ERROR: Remote tag ${tagName} already exists but points at ${remoteTagCommit}, not this release commit ${releaseCommit}`);
console.error(` Refusing to move or overwrite an existing release tag. Investigate before retrying.`);
process.exit(1);
} else if (dryRun) {
console.log(` [DRY RUN] Remote tag ${tagName} not found β would create annotated tag and push to origin.`);
} else {
// Tag does not exist on the remote β create (or reconcile a stale local
// tag from a prior partial run) and push it.
let localTagCommit = null;
try {
localTagCommit = execSync(`git rev-list -n 1 "${tagName}"`, { cwd: ROOT_DIR, encoding: 'utf8' }).trim();
} catch (e) {
localTagCommit = null; // no local tag
}
if (localTagCommit && localTagCommit !== releaseCommit) {
// Stale local tag from an earlier attempt at a different commit β recreate.
console.log(`β οΈ Local tag ${tagName} points at ${localTagCommit}, recreating it at the release commit...`);
execSync(`git tag -d "${tagName}"`, { cwd: ROOT_DIR, stdio: 'inherit' });
localTagCommit = null;
}
if (!localTagCommit) {
console.log(`π·οΈ Creating git tag: ${tagName}...`);
execSync(`git tag -a "${tagName}" -m "Release ${tagName}"`, { cwd: ROOT_DIR, stdio: 'inherit' });
} else {
console.log(`π·οΈ Local tag ${tagName} already matches the release commit β reusing it.`);
}
console.log(`π€ Pushing tag ${tagName}...`);
execSync(`git push origin "${tagName}"`, { cwd: ROOT_DIR, stdio: 'inherit' });
}
// 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 };