Skip to content

Commit

Permalink
[code-infra] Add canary release scripts (#41949)
Browse files Browse the repository at this point in the history
  • Loading branch information
michaldudak committed May 8, 2024
1 parent efba47d commit 8f88c8b
Show file tree
Hide file tree
Showing 2 changed files with 266 additions and 2 deletions.
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
"deduplicate": "pnpm dedupe",
"benchmark:browser": "pnpm --filter benchmark browser",
"build": "lerna run build --ignore docs",
"build:public": "lerna run --no-private build",
"build:ci": "lerna run build --ignore docs --concurrency 8 --skip-nx-cache",
"build:public": "lerna run --no-private build",
"build:public:ci": "lerna run --no-private build --concurrency 8 --skip-nx-cache",
"build:codesandbox": "NODE_OPTIONS=\"--max_old_space_size=4096\" lerna run --concurrency 8 --scope \"@mui/*\" --scope \"@mui-internal/*\" --no-private build",
"release:version": "lerna version --no-changelog --no-push --no-git-tag-version --no-private --force-publish=@mui/core-downloads-tracker",
"release:build": "lerna run --concurrency 8 --no-private build --skip-nx-cache",
Expand Down Expand Up @@ -76,7 +77,8 @@
"typescript": "lerna run --no-bail --parallel typescript",
"typescript:ci": "lerna run --concurrency 3 --no-bail --no-sort typescript",
"validate-declarations": "tsx scripts/validateTypescriptDeclarations.mts",
"generate-codeowners": "node scripts/generateCodeowners.mjs"
"generate-codeowners": "node scripts/generateCodeowners.mjs",
"canary:release": "tsx ./scripts/canaryRelease.mts"
},
"dependencies": {
"@googleapis/sheets": "^5.0.5",
Expand Down
262 changes: 262 additions & 0 deletions scripts/canaryRelease.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
/* eslint-disable prefer-template */
/* eslint-disable no-restricted-syntax */
/* eslint-disable no-console */
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFile, writeFile, appendFile } from 'node:fs/promises';
import * as readline from 'node:readline/promises';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { $ } from 'execa';
import chalk from 'chalk';

const $$ = $({ stdio: 'inherit' });

const currentDirectory = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(currentDirectory, '..');

interface PackageInfo {
name: string;
path: string;
version: string;
private: boolean;
}

interface RunOptions {
accessToken?: string;
baseline?: string;
dryRun: boolean;
skipLastCommitComparison: boolean;
yes: boolean;
ignore: string[];
}

async function run({
dryRun,
accessToken,
baseline,
skipLastCommitComparison,
yes,
ignore,
}: RunOptions) {
await ensureCleanWorkingDirectory();

const changedPackages = await getChangedPackages(baseline, skipLastCommitComparison, ignore);
if (changedPackages.length === 0) {
return;
}

await confirmPublishing(changedPackages, yes);

try {
await setAccessToken(accessToken);
await setVersion(changedPackages);
await buildPackages();
await publishPackages(changedPackages, dryRun);
} finally {
await cleanUp();
}
}

async function ensureCleanWorkingDirectory() {
try {
await $`git diff --quiet`;
await $`git diff --quiet --cached`;
} catch (error) {
console.error('❌ Working directory is not clean.');
process.exit(1);
}
}

async function listPublicChangedPackages(baseline: string) {
const { stdout: packagesJson } =
await $`pnpm list --recursive --filter ...[${baseline}] --depth -1 --only-projects --json`;
const packages = JSON.parse(packagesJson) as PackageInfo[];
return packages.filter((pkg) => !pkg.private);
}

async function getChangedPackages(
baseline: string | undefined,
skipLastCommitComparison: boolean,
ignore: string[],
): Promise<PackageInfo[]> {
if (!skipLastCommitComparison) {
const publicPackagesUpdatedInLastCommit = await listPublicChangedPackages('HEAD~1');
if (publicPackagesUpdatedInLastCommit.length === 0) {
console.log('No public packages changed in the last commit.');
return [];
}
}

if (!baseline) {
const { stdout: latestTag } = await $`git describe --abbrev=0`;
baseline = latestTag;
}

console.log(`Looking for changed public packages since ${chalk.yellow(baseline)}...`);

const changedPackages = (await listPublicChangedPackages(baseline)).filter(
(p) => !ignore.includes(p.name),
);

if (changedPackages.length === 0) {
console.log('Nothing found.');
}

return changedPackages;
}

async function confirmPublishing(changedPackages: PackageInfo[], yes: boolean) {
if (!yes) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});

console.log('\nFound changes in the following packages:');
for (const pkg of changedPackages) {
console.log(` - ${pkg.name}`);
}

console.log('\nThis will publish the above packages to the npm registry.');
const answer = await rl.question('Do you want to proceed? (y/n) ');

rl.close();

if (answer.toLowerCase() !== 'y') {
console.log('Aborted.');
process.exit(0);
}
}
}

async function setAccessToken(npmAccessToken: string | undefined) {
if (!npmAccessToken && !process.env.NPM_TOKEN) {
console.error(
'❌ NPM access token is required. Either pass it as an --access-token argument or set it as an NPM_TOKEN environment variable.',
);
process.exit(1);
}

const npmrcPath = resolve(workspaceRoot, '.npmrc');

await appendFile(
npmrcPath,
`//registry.npmjs.org/:_authToken=${npmAccessToken ?? process.env.NPM_TOKEN}\n`,
);
}

async function setVersion(packages: PackageInfo[]) {
const { stdout: currentRevisionSha } = await $`git rev-parse --short HEAD`;
const { stdout: commitTimestamp } = await $`git show --no-patch --format=%ct HEAD`;
const timestamp = formatDate(new Date(+commitTimestamp * 1000));
let hasError = false;

const tasks = packages.map(async (pkg) => {
const packageJsonPath = resolve(pkg.path, './package.json');
try {
const packageJson = JSON.parse(await readFile(packageJsonPath, { encoding: 'utf8' }));
const version = packageJson.version;
const dashIndex = version.indexOf('-');
let newVersion = version;
if (dashIndex !== -1) {
newVersion = version.slice(0, dashIndex);
}

newVersion = `${newVersion}-dev.${timestamp}-${currentRevisionSha}`;
packageJson.version = newVersion;

await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
} catch (error) {
console.error(`${chalk.red(`❌ ${packageJsonPath}`)}`, error);
hasError = true;
}
});

await Promise.allSettled(tasks);
if (hasError) {
throw new Error('Failed to update package versions');
}
}

function formatDate(date: Date) {
// yyyyMMdd-HHmmss
return date
.toISOString()
.replace(/[-:Z.]/g, '')
.replace('T', '-')
.slice(0, 15);
}

function buildPackages() {
if (process.env.CI) {
return $$`pnpm build:public:ci`;
}

return $$`pnpm build:public`;
}

async function publishPackages(packages: PackageInfo[], dryRun: boolean) {
console.log(`\nPublishing packages${dryRun ? ' (dry run)' : ''}`);
const tasks = packages.map(async (pkg) => {
try {
const args = [pkg.path, '--tag', 'canary', '--no-git-checks'];
if (dryRun) {
args.push('--dry-run');
}
await $$`pnpm publish ${args}`;
} catch (error: any) {
console.error(chalk.red(`❌ ${pkg.name}`), error.shortMessage);
}
});

await Promise.allSettled(tasks);
}

async function cleanUp() {
await $`git restore .`;
}

yargs(hideBin(process.argv))
.command<RunOptions>(
'$0',
'Publishes packages that have changed since the last release (or a specified commit).',
(command) => {
return command
.option('dryRun', {
default: false,
describe: 'If true, no packages will be published to the registry.',
type: 'boolean',
})
.option('accessToken', {
describe: 'NPM access token',
type: 'string',
})
.option('baseline', {
describe: 'Baseline tag or commit to compare against (for example `master`).',
type: 'string',
})
.option('skipLastCommitComparison', {
default: false,
describe:
'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.',
type: 'boolean',
})
.option('yes', {
default: false,
describe: "If set, the script doesn't ask for confirmation before publishing packages",
type: 'boolean',
})
.option('ignore', {
describe: 'List of packages to ignore',
type: 'string',
array: true,
default: [],
});
},
run,
)
.help()
.strict(true)
.version(false)
.parse();

0 comments on commit 8f88c8b

Please sign in to comment.