Skip to content

Commit 8f88c8b

Browse files
authored
[code-infra] Add canary release scripts (#41949)
1 parent efba47d commit 8f88c8b

File tree

2 files changed

+266
-2
lines changed

2 files changed

+266
-2
lines changed

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
"deduplicate": "pnpm dedupe",
99
"benchmark:browser": "pnpm --filter benchmark browser",
1010
"build": "lerna run build --ignore docs",
11-
"build:public": "lerna run --no-private build",
1211
"build:ci": "lerna run build --ignore docs --concurrency 8 --skip-nx-cache",
12+
"build:public": "lerna run --no-private build",
13+
"build:public:ci": "lerna run --no-private build --concurrency 8 --skip-nx-cache",
1314
"build:codesandbox": "NODE_OPTIONS=\"--max_old_space_size=4096\" lerna run --concurrency 8 --scope \"@mui/*\" --scope \"@mui-internal/*\" --no-private build",
1415
"release:version": "lerna version --no-changelog --no-push --no-git-tag-version --no-private --force-publish=@mui/core-downloads-tracker",
1516
"release:build": "lerna run --concurrency 8 --no-private build --skip-nx-cache",
@@ -76,7 +77,8 @@
7677
"typescript": "lerna run --no-bail --parallel typescript",
7778
"typescript:ci": "lerna run --concurrency 3 --no-bail --no-sort typescript",
7879
"validate-declarations": "tsx scripts/validateTypescriptDeclarations.mts",
79-
"generate-codeowners": "node scripts/generateCodeowners.mjs"
80+
"generate-codeowners": "node scripts/generateCodeowners.mjs",
81+
"canary:release": "tsx ./scripts/canaryRelease.mts"
8082
},
8183
"dependencies": {
8284
"@googleapis/sheets": "^5.0.5",

scripts/canaryRelease.mts

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
/* eslint-disable prefer-template */
2+
/* eslint-disable no-restricted-syntax */
3+
/* eslint-disable no-console */
4+
import { resolve, dirname } from 'node:path';
5+
import { fileURLToPath } from 'node:url';
6+
import { readFile, writeFile, appendFile } from 'node:fs/promises';
7+
import * as readline from 'node:readline/promises';
8+
import yargs from 'yargs';
9+
import { hideBin } from 'yargs/helpers';
10+
import { $ } from 'execa';
11+
import chalk from 'chalk';
12+
13+
const $$ = $({ stdio: 'inherit' });
14+
15+
const currentDirectory = dirname(fileURLToPath(import.meta.url));
16+
const workspaceRoot = resolve(currentDirectory, '..');
17+
18+
interface PackageInfo {
19+
name: string;
20+
path: string;
21+
version: string;
22+
private: boolean;
23+
}
24+
25+
interface RunOptions {
26+
accessToken?: string;
27+
baseline?: string;
28+
dryRun: boolean;
29+
skipLastCommitComparison: boolean;
30+
yes: boolean;
31+
ignore: string[];
32+
}
33+
34+
async function run({
35+
dryRun,
36+
accessToken,
37+
baseline,
38+
skipLastCommitComparison,
39+
yes,
40+
ignore,
41+
}: RunOptions) {
42+
await ensureCleanWorkingDirectory();
43+
44+
const changedPackages = await getChangedPackages(baseline, skipLastCommitComparison, ignore);
45+
if (changedPackages.length === 0) {
46+
return;
47+
}
48+
49+
await confirmPublishing(changedPackages, yes);
50+
51+
try {
52+
await setAccessToken(accessToken);
53+
await setVersion(changedPackages);
54+
await buildPackages();
55+
await publishPackages(changedPackages, dryRun);
56+
} finally {
57+
await cleanUp();
58+
}
59+
}
60+
61+
async function ensureCleanWorkingDirectory() {
62+
try {
63+
await $`git diff --quiet`;
64+
await $`git diff --quiet --cached`;
65+
} catch (error) {
66+
console.error('❌ Working directory is not clean.');
67+
process.exit(1);
68+
}
69+
}
70+
71+
async function listPublicChangedPackages(baseline: string) {
72+
const { stdout: packagesJson } =
73+
await $`pnpm list --recursive --filter ...[${baseline}] --depth -1 --only-projects --json`;
74+
const packages = JSON.parse(packagesJson) as PackageInfo[];
75+
return packages.filter((pkg) => !pkg.private);
76+
}
77+
78+
async function getChangedPackages(
79+
baseline: string | undefined,
80+
skipLastCommitComparison: boolean,
81+
ignore: string[],
82+
): Promise<PackageInfo[]> {
83+
if (!skipLastCommitComparison) {
84+
const publicPackagesUpdatedInLastCommit = await listPublicChangedPackages('HEAD~1');
85+
if (publicPackagesUpdatedInLastCommit.length === 0) {
86+
console.log('No public packages changed in the last commit.');
87+
return [];
88+
}
89+
}
90+
91+
if (!baseline) {
92+
const { stdout: latestTag } = await $`git describe --abbrev=0`;
93+
baseline = latestTag;
94+
}
95+
96+
console.log(`Looking for changed public packages since ${chalk.yellow(baseline)}...`);
97+
98+
const changedPackages = (await listPublicChangedPackages(baseline)).filter(
99+
(p) => !ignore.includes(p.name),
100+
);
101+
102+
if (changedPackages.length === 0) {
103+
console.log('Nothing found.');
104+
}
105+
106+
return changedPackages;
107+
}
108+
109+
async function confirmPublishing(changedPackages: PackageInfo[], yes: boolean) {
110+
if (!yes) {
111+
const rl = readline.createInterface({
112+
input: process.stdin,
113+
output: process.stdout,
114+
});
115+
116+
console.log('\nFound changes in the following packages:');
117+
for (const pkg of changedPackages) {
118+
console.log(` - ${pkg.name}`);
119+
}
120+
121+
console.log('\nThis will publish the above packages to the npm registry.');
122+
const answer = await rl.question('Do you want to proceed? (y/n) ');
123+
124+
rl.close();
125+
126+
if (answer.toLowerCase() !== 'y') {
127+
console.log('Aborted.');
128+
process.exit(0);
129+
}
130+
}
131+
}
132+
133+
async function setAccessToken(npmAccessToken: string | undefined) {
134+
if (!npmAccessToken && !process.env.NPM_TOKEN) {
135+
console.error(
136+
'❌ NPM access token is required. Either pass it as an --access-token argument or set it as an NPM_TOKEN environment variable.',
137+
);
138+
process.exit(1);
139+
}
140+
141+
const npmrcPath = resolve(workspaceRoot, '.npmrc');
142+
143+
await appendFile(
144+
npmrcPath,
145+
`//registry.npmjs.org/:_authToken=${npmAccessToken ?? process.env.NPM_TOKEN}\n`,
146+
);
147+
}
148+
149+
async function setVersion(packages: PackageInfo[]) {
150+
const { stdout: currentRevisionSha } = await $`git rev-parse --short HEAD`;
151+
const { stdout: commitTimestamp } = await $`git show --no-patch --format=%ct HEAD`;
152+
const timestamp = formatDate(new Date(+commitTimestamp * 1000));
153+
let hasError = false;
154+
155+
const tasks = packages.map(async (pkg) => {
156+
const packageJsonPath = resolve(pkg.path, './package.json');
157+
try {
158+
const packageJson = JSON.parse(await readFile(packageJsonPath, { encoding: 'utf8' }));
159+
const version = packageJson.version;
160+
const dashIndex = version.indexOf('-');
161+
let newVersion = version;
162+
if (dashIndex !== -1) {
163+
newVersion = version.slice(0, dashIndex);
164+
}
165+
166+
newVersion = `${newVersion}-dev.${timestamp}-${currentRevisionSha}`;
167+
packageJson.version = newVersion;
168+
169+
await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
170+
} catch (error) {
171+
console.error(`${chalk.red(`❌ ${packageJsonPath}`)}`, error);
172+
hasError = true;
173+
}
174+
});
175+
176+
await Promise.allSettled(tasks);
177+
if (hasError) {
178+
throw new Error('Failed to update package versions');
179+
}
180+
}
181+
182+
function formatDate(date: Date) {
183+
// yyyyMMdd-HHmmss
184+
return date
185+
.toISOString()
186+
.replace(/[-:Z.]/g, '')
187+
.replace('T', '-')
188+
.slice(0, 15);
189+
}
190+
191+
function buildPackages() {
192+
if (process.env.CI) {
193+
return $$`pnpm build:public:ci`;
194+
}
195+
196+
return $$`pnpm build:public`;
197+
}
198+
199+
async function publishPackages(packages: PackageInfo[], dryRun: boolean) {
200+
console.log(`\nPublishing packages${dryRun ? ' (dry run)' : ''}`);
201+
const tasks = packages.map(async (pkg) => {
202+
try {
203+
const args = [pkg.path, '--tag', 'canary', '--no-git-checks'];
204+
if (dryRun) {
205+
args.push('--dry-run');
206+
}
207+
await $$`pnpm publish ${args}`;
208+
} catch (error: any) {
209+
console.error(chalk.red(`❌ ${pkg.name}`), error.shortMessage);
210+
}
211+
});
212+
213+
await Promise.allSettled(tasks);
214+
}
215+
216+
async function cleanUp() {
217+
await $`git restore .`;
218+
}
219+
220+
yargs(hideBin(process.argv))
221+
.command<RunOptions>(
222+
'$0',
223+
'Publishes packages that have changed since the last release (or a specified commit).',
224+
(command) => {
225+
return command
226+
.option('dryRun', {
227+
default: false,
228+
describe: 'If true, no packages will be published to the registry.',
229+
type: 'boolean',
230+
})
231+
.option('accessToken', {
232+
describe: 'NPM access token',
233+
type: 'string',
234+
})
235+
.option('baseline', {
236+
describe: 'Baseline tag or commit to compare against (for example `master`).',
237+
type: 'string',
238+
})
239+
.option('skipLastCommitComparison', {
240+
default: false,
241+
describe:
242+
'By default, the script exits when there are no changes in public packages in the latest commit. Setting this flag will skip this check and compare only against the baseline.',
243+
type: 'boolean',
244+
})
245+
.option('yes', {
246+
default: false,
247+
describe: "If set, the script doesn't ask for confirmation before publishing packages",
248+
type: 'boolean',
249+
})
250+
.option('ignore', {
251+
describe: 'List of packages to ignore',
252+
type: 'string',
253+
array: true,
254+
default: [],
255+
});
256+
},
257+
run,
258+
)
259+
.help()
260+
.strict(true)
261+
.version(false)
262+
.parse();

0 commit comments

Comments
 (0)