@mui/internal-code-infra 0.1.1-canary.20 → 0.1.1-canary.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,12 +24,15 @@ This is stored in the `docs` top-level directory.
24
24
  > - **Commit SHA to release from:** the commit that contains the merged release on master. This commit is linked to the GitHub release.
25
25
  > - **Run in dry-run mode:** Used for debugging.
26
26
  > - **Create GitHub release:** Keep selected if you want a GitHub release to be automatically created from the changelog.
27
- > - **npm dist tag to publish to** Use to publish legacy or canary versions.
27
+ > - **npm dist tag to publish to** Use to publish legacy or canary versions. The publish fails before it publishes anything when a dist-tag would move to a lower version. `latest` and `latest-*` also cannot move to a prerelease unless they already point to one, and `latest-*` cannot change its major.
28
28
 
29
29
  3. Click "Run workflow"
30
30
  4. Refresh the page to see the newly created workflow, and click it.
31
31
  5. The next screen shows "@username requested your review to deploy to npm-publish", click "Review deployments" and authorize your workflow run. **Never approve workflow runs you didn't initiaite.**
32
32
 
33
+ > [!NOTE]
34
+ > A CI dry run (`code-infra publish --ci --dry-run`) runs the same dist-tag check. On a branch that does not publish to `latest`, pass the dist-tag of the release to it, for example `--tag next` for prereleases or `--tag latest-v8` for a previous major.
35
+
33
36
  > [!IMPORTANT]
34
37
  > Go through the below steps if there is an error that says `The following packages are new and need to be published manually first` in the publish flow.
35
38
 
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ /** Version that `publish-new-package` publishes to reserve the name of a new package. */
3
+ export declare const PLACEHOLDER_VERSION = "0.0.1";
2
4
  export type PrivatePackage = {
3
5
  /**
4
6
  * - Package name
@@ -122,6 +124,43 @@ export declare function getPublishRegistry(packagePath: string): Promise<string>
122
124
  * @returns {Promise<string | null>} Version string, or null when absent/invalid
123
125
  */
124
126
  export declare function getReleaseVersion(cwd?: string): Promise<string | null>;
127
+ export type RegistryPackage = {
128
+ /**
129
+ * - Registry that the package publishes to
130
+ */
131
+ registry: string;
132
+ /**
133
+ * - Whether the package is on the registry
134
+ */
135
+ exists: boolean;
136
+ /**
137
+ * - Dist-tags of the package
138
+ */
139
+ distTags: Record<string, string>;
140
+ /**
141
+ * - Published versions of the package
142
+ */
143
+ versions: string[];
144
+ };
145
+ /**
146
+ * @typedef {Object} RegistryPackage
147
+ * @property {string} registry - Registry that the package publishes to
148
+ * @property {boolean} exists - Whether the package is on the registry
149
+ * @property {Record<string, string>} distTags - Dist-tags of the package
150
+ * @property {string[]} versions - Published versions of the package
151
+ */
152
+ /**
153
+ * Read each package from its publish registry with `pnpm view`, so the lookup uses the
154
+ * same registry configuration and credentials as `pnpm publish`.
155
+ *
156
+ * @param {PublicPackage[]} packages - Packages to read
157
+ * @param {{ npmOnly?: boolean }} [options] - `npmOnly` reads only the packages that publish to npm.
158
+ * Checks that only concern npm use it, so a missing credential for another registry does not block them.
159
+ * @returns {Promise<Map<string, RegistryPackage>>} By package name
160
+ */
161
+ export declare function getRegistryPackages(packages: PublicPackage[], { npmOnly }?: {
162
+ npmOnly?: boolean;
163
+ }): Promise<Map<string, RegistryPackage>>;
125
164
  /**
126
165
  * Filter to the packages that must be published by hand before CI can take over.
127
166
  *
@@ -131,14 +170,39 @@ export declare function getReleaseVersion(cwd?: string): Promise<string | null>;
131
170
  * midway through. See {@link getPackageVersionInfo} for the version-level check.
132
171
  *
133
172
  * @param {PublicPackage[]} packages - Packages to check
134
- * @returns {Promise<PublicPackage[]>} The subset needing a manual first publish
173
+ * @param {Map<string, RegistryPackage>} registryPackages - Registry data from {@link getRegistryPackages}
174
+ * @returns {PublicPackage[]} The subset needing a manual first publish
175
+ */
176
+ export declare function getPackagesNeedingManualPublish(packages: PublicPackage[], registryPackages: Map<string, RegistryPackage>): PublicPackage[];
177
+ export type DistTagConflict = {
178
+ /**
179
+ * - Package to publish
180
+ */
181
+ pkg: PublicPackage;
182
+ /**
183
+ * - Why the dist-tag would move to the wrong release line
184
+ */
185
+ reason: string;
186
+ };
187
+ /**
188
+ * @typedef {Object} DistTagConflict
189
+ * @property {PublicPackage} pkg - Package to publish
190
+ * @property {string} reason - Why the dist-tag would move to the wrong release line
191
+ */
192
+ /**
193
+ * Find the packages whose publish with `tag` would move the dist-tag to the wrong release line.
194
+ *
195
+ * @param {PublicPackage[]} packages - Packages to publish
196
+ * @param {string} tag - Dist-tag to publish with
197
+ * @param {Map<string, RegistryPackage>} registryPackages - Registry data from {@link getRegistryPackages}
198
+ * @returns {DistTagConflict[]}
135
199
  */
136
- export declare function getPackagesNeedingManualPublish(packages: PublicPackage[]): Promise<PublicPackage[]>;
200
+ export declare function getDistTagConflicts(packages: PublicPackage[], tag: string, registryPackages: Map<string, RegistryPackage>): DistTagConflict[];
137
201
  /**
138
202
  * Get package version info from registry.
139
203
  *
140
204
  * Resolves through `pnpm view`, which reports a lookup failure the same way it
141
- * reports a missing version. Use {@link getPackagesNeedingManualPublish} where
205
+ * reports a missing version. Use {@link getRegistryPackages} where
142
206
  * absence has to be told apart from an error.
143
207
  *
144
208
  * @param {string} packageName - Name of the package
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mui/internal-code-infra",
3
- "version": "0.1.1-canary.20",
3
+ "version": "0.1.1-canary.22",
4
4
  "author": "MUI Team",
5
5
  "description": "Infra scripts and configs to be used across MUI repos.",
6
6
  "license": "MIT",
@@ -190,7 +190,7 @@
190
190
  "publishConfig": {
191
191
  "access": "public"
192
192
  },
193
- "gitSha": "98dec53f859a1480bed0e813be9a37cce8fea796",
193
+ "gitSha": "7be938414716e74df30e861271c65d7885da8c98",
194
194
  "scripts": {
195
195
  "build": "tsc -p tsconfig.build.json",
196
196
  "typescript": "tsc -noEmit",
@@ -19,7 +19,9 @@ import * as semver from 'semver';
19
19
 
20
20
  import { persistentAuthStrategy } from '../utils/github.mjs';
21
21
  import {
22
+ getDistTagConflicts,
22
23
  getPackagesNeedingManualPublish,
24
+ getRegistryPackages,
23
25
  getReleaseVersion,
24
26
  getWorkspacePackages,
25
27
  publishPackages,
@@ -364,7 +366,8 @@ export default /** @type {import('yargs').CommandModule<{}, Args>} */ ({
364
366
  githubReleaseData = await validateGitHubRelease(version);
365
367
  }
366
368
 
367
- const newPackages = await getPackagesNeedingManualPublish(filteredPackages);
369
+ const registryPackages = await getRegistryPackages(filteredPackages);
370
+ const newPackages = getPackagesNeedingManualPublish(filteredPackages, registryPackages);
368
371
 
369
372
  if (newPackages.length > 0) {
370
373
  const newPackageNames = newPackages.map((pkg) => pkg.name).join(', ');
@@ -373,6 +376,15 @@ export default /** @type {import('yargs').CommandModule<{}, Args>} */ ({
373
376
  );
374
377
  }
375
378
 
379
+ const distTagConflicts = getDistTagConflicts(filteredPackages, tag, registryPackages);
380
+ if (distTagConflicts.length > 0) {
381
+ throw new Error(
382
+ `Publishing with the "${tag}" dist-tag would move it to the wrong release line:
383
+ ${distTagConflicts.map(({ pkg, reason }) => ` * ${pkg.name}: ${reason}`).join('\n')}
384
+ Publish a previous major with its "latest-vX" dist-tag and a prerelease of the next major with "next". On a release branch, also make that dist-tag the "dist-tag" default of the publish workflow. If the dist-tag on npm is outdated, fix it with "npm dist-tag" first.`,
385
+ );
386
+ }
387
+
376
388
  // Publish to npm (pnpm handles duplicate checking automatically)
377
389
  // No git checks, we'll do our own
378
390
  console.log('\n📦 Publishing packages to npm...');
@@ -426,8 +438,10 @@ const PUBLISH_WORKFLOW_ID = `.github/${WORKFLOW_PATH}`;
426
438
  */
427
439
  async function triggerLocalGithubPublishWorkflow(opts) {
428
440
  console.log(`🔍 Checking if there are new packages to publish in the workspace...`);
429
- const newPackages = await getPackagesNeedingManualPublish(
430
- await getWorkspacePackages({ publicOnly: true }),
441
+ const packages = await getWorkspacePackages({ publicOnly: true });
442
+ const newPackages = getPackagesNeedingManualPublish(
443
+ packages,
444
+ await getRegistryPackages(packages, { npmOnly: true }),
431
445
  );
432
446
  if (newPackages.length) {
433
447
  console.warn(
@@ -11,7 +11,12 @@ import { findWorkspaceDir } from '@pnpm/find-workspace-dir';
11
11
 
12
12
  import { getRepositoryInfo } from '../utils/git.mjs';
13
13
  import { toPosixPath } from '../utils/path.mjs';
14
- import { getPackagesNeedingManualPublish, getWorkspacePackages } from '../utils/pnpm.mjs';
14
+ import {
15
+ getPackagesNeedingManualPublish,
16
+ getRegistryPackages,
17
+ getWorkspacePackages,
18
+ PLACEHOLDER_VERSION,
19
+ } from '../utils/pnpm.mjs';
15
20
 
16
21
  /**
17
22
  * @typedef {Object} Args
@@ -46,8 +51,10 @@ export default /** @type {import('yargs').CommandModule<{}, Args>} */ ({
46
51
  }),
47
52
  async handler(args) {
48
53
  console.log(`🔍 Detecting new packages to publish in workspace...`);
49
- const newPackages = await getPackagesNeedingManualPublish(
50
- await getWorkspacePackages({ publicOnly: true }),
54
+ const packages = await getWorkspacePackages({ publicOnly: true });
55
+ const newPackages = getPackagesNeedingManualPublish(
56
+ packages,
57
+ await getRegistryPackages(packages, { npmOnly: true }),
51
58
  );
52
59
 
53
60
  if (!newPackages.length) {
@@ -78,7 +85,7 @@ export default /** @type {import('yargs').CommandModule<{}, Args>} */ ({
78
85
  const repo = await getRepositoryInfo();
79
86
  const packageJson = {
80
87
  name: pkg.name,
81
- version: '0.0.1',
88
+ version: PLACEHOLDER_VERSION,
82
89
  repository: {
83
90
  type: 'git',
84
91
  url: `git+https://github.com/${repo.owner}/${repo.repo}.git`,
@@ -12,6 +12,9 @@ import { parseDocument, isMap } from 'yaml';
12
12
  /** Canonical npm registry URL, in the form `getPublishRegistry` returns. */
13
13
  const NPMJS_REGISTRY = 'https://registry.npmjs.org/';
14
14
 
15
+ /** Version that `publish-new-package` publishes to reserve the name of a new package. */
16
+ export const PLACEHOLDER_VERSION = '0.0.1';
17
+
15
18
  /**
16
19
  * @typedef {Object} PrivatePackage
17
20
  * @property {string} [name] - Package name
@@ -189,49 +192,147 @@ function requiresTrustedPublisherBootstrap(registry) {
189
192
  }
190
193
 
191
194
  /**
192
- * Filter to the packages that must be published by hand before CI can take over.
193
- *
194
- * A brand new package has to be pushed once manually (see
195
- * `code-infra publish-new-package`) before the OIDC-based workflow can publish
196
- * it, so the release fails with a clear message instead of a confusing 404
197
- * midway through. See {@link getPackageVersionInfo} for the version-level check.
195
+ * @typedef {Object} RegistryPackage
196
+ * @property {string} registry - Registry that the package publishes to
197
+ * @property {boolean} exists - Whether the package is on the registry
198
+ * @property {Record<string, string>} distTags - Dist-tags of the package
199
+ * @property {string[]} versions - Published versions of the package
200
+ */
201
+
202
+ /**
203
+ * Read each package from its publish registry with `pnpm view`, so the lookup uses the
204
+ * same registry configuration and credentials as `pnpm publish`.
198
205
  *
199
- * @param {PublicPackage[]} packages - Packages to check
200
- * @returns {Promise<PublicPackage[]>} The subset needing a manual first publish
206
+ * @param {PublicPackage[]} packages - Packages to read
207
+ * @param {{ npmOnly?: boolean }} [options] - `npmOnly` reads only the packages that publish to npm.
208
+ * Checks that only concern npm use it, so a missing credential for another registry does not block them.
209
+ * @returns {Promise<Map<string, RegistryPackage>>} By package name
201
210
  */
202
- export async function getPackagesNeedingManualPublish(packages) {
203
- const results = await Promise.all(
211
+ export async function getRegistryPackages(packages, { npmOnly = false } = {}) {
212
+ const entries = await Promise.all(
204
213
  packages.map(async (pkg) => {
205
214
  const registry = await getPublishRegistry(pkg.path);
206
- if (!requiresTrustedPublisherBootstrap(registry)) {
207
- return false;
215
+ if (npmOnly && registry !== NPMJS_REGISTRY) {
216
+ return null;
208
217
  }
209
-
210
- // HEAD, because only the status matters — a packument runs to megabytes.
211
- const res = await fetch(new URL(pkg.name, registry), { method: 'HEAD' });
212
- if (res.status === 404) {
213
- return true;
218
+ const { exitCode, stdout, stderr } = await $({
219
+ cwd: pkg.path,
220
+ reject: false,
221
+ // pnpm resolves its own `packageManager` version from `--registry`, which may not serve it.
222
+ env: { PNPM_CONFIG_PM_ON_FAIL: 'ignore' },
223
+ })`pnpm view ${pkg.name} dist-tags versions --json --registry ${registry}`;
224
+ let info;
225
+ try {
226
+ info = JSON.parse(stdout || '{}');
227
+ } catch {
228
+ info = {};
214
229
  }
215
- if (!res.ok) {
216
- // Anything else (401, 5xx, a proxy hiccup) tells us nothing about
217
- // whether the package exists. Treating it as "already published" would
218
- // silently disable the check, so fail loudly instead.
230
+ // Anything but a 404 tells nothing about whether the package exists. Treating it as
231
+ // "not published" would silently disable the checks, so fail loudly instead.
232
+ if (exitCode !== 0 && info.error?.code !== 'ERR_PNPM_FETCH_404') {
219
233
  throw new Error(
220
- `Failed to check whether ${pkg.name} exists on ${registry}: HTTP ${res.status}`,
234
+ `Failed to read ${pkg.name} from ${registry}: ${info.error?.message ?? stderr}`,
221
235
  );
222
236
  }
223
- return false;
237
+ /** @type {RegistryPackage} */
238
+ const registryPackage = {
239
+ registry,
240
+ exists: exitCode === 0,
241
+ distTags: info['dist-tags'] ?? {},
242
+ versions: [info.versions ?? []].flat(),
243
+ };
244
+ return /** @type {const} */ ([pkg.name, registryPackage]);
224
245
  }),
225
246
  );
247
+ return new Map(entries.filter((entry) => entry !== null));
248
+ }
226
249
 
227
- return packages.filter((_pkg, index) => results[index]);
250
+ /**
251
+ * Filter to the packages that must be published by hand before CI can take over.
252
+ *
253
+ * A brand new package has to be pushed once manually (see
254
+ * `code-infra publish-new-package`) before the OIDC-based workflow can publish
255
+ * it, so the release fails with a clear message instead of a confusing 404
256
+ * midway through. See {@link getPackageVersionInfo} for the version-level check.
257
+ *
258
+ * @param {PublicPackage[]} packages - Packages to check
259
+ * @param {Map<string, RegistryPackage>} registryPackages - Registry data from {@link getRegistryPackages}
260
+ * @returns {PublicPackage[]} The subset needing a manual first publish
261
+ */
262
+ export function getPackagesNeedingManualPublish(packages, registryPackages) {
263
+ return packages.filter((pkg) => {
264
+ const registryPackage = registryPackages.get(pkg.name);
265
+ return (
266
+ registryPackage !== undefined &&
267
+ !registryPackage.exists &&
268
+ requiresTrustedPublisherBootstrap(registryPackage.registry)
269
+ );
270
+ });
271
+ }
272
+
273
+ /**
274
+ * Why publishing `version` with `tag` would move the dist-tag to the wrong release line.
275
+ *
276
+ * @param {string} tag - Dist-tag to publish with
277
+ * @param {string} version - Version to publish
278
+ * @param {string} current - Version that the dist-tag points to on the registry
279
+ * @returns {string | null} The reason, or null when the publish is safe
280
+ */
281
+ function getDistTagConflict(tag, version, current) {
282
+ if (semver.lt(version, current)) {
283
+ return `"${tag}" can only move to a newer version, but ${version} is lower than the current ${current}`;
284
+ }
285
+ // A tag that already points to a prerelease can move to the next one, for packages still in beta.
286
+ if (
287
+ (tag === 'latest' || tag.startsWith('latest-')) &&
288
+ semver.prerelease(version) &&
289
+ !semver.prerelease(current)
290
+ ) {
291
+ return `"${tag}" cannot point to a prerelease (${version})`;
292
+ }
293
+ if (tag.startsWith('latest-') && semver.major(version) !== semver.major(current)) {
294
+ return `"${tag}" cannot cross a major (${current} to ${version})`;
295
+ }
296
+ return null;
297
+ }
298
+
299
+ /**
300
+ * @typedef {Object} DistTagConflict
301
+ * @property {PublicPackage} pkg - Package to publish
302
+ * @property {string} reason - Why the dist-tag would move to the wrong release line
303
+ */
304
+
305
+ /**
306
+ * Find the packages whose publish with `tag` would move the dist-tag to the wrong release line.
307
+ *
308
+ * @param {PublicPackage[]} packages - Packages to publish
309
+ * @param {string} tag - Dist-tag to publish with
310
+ * @param {Map<string, RegistryPackage>} registryPackages - Registry data from {@link getRegistryPackages}
311
+ * @returns {DistTagConflict[]}
312
+ */
313
+ export function getDistTagConflicts(packages, tag, registryPackages) {
314
+ return packages.flatMap((pkg) => {
315
+ const registryPackage = registryPackages.get(pkg.name);
316
+ // pnpm skips versions that are already published, such as unchanged packages on a legacy branch.
317
+ if (!registryPackage || registryPackage.versions.includes(pkg.version)) {
318
+ return [];
319
+ }
320
+ // A package that is not on the registry yet has no dist-tags to protect, and npm points
321
+ // `latest` at the first version, which is the placeholder for a new package.
322
+ const current = semver.valid(registryPackage.distTags[tag]);
323
+ if (!current || current === PLACEHOLDER_VERSION) {
324
+ return [];
325
+ }
326
+ const reason = getDistTagConflict(tag, pkg.version, current);
327
+ return reason ? [{ pkg, reason }] : [];
328
+ });
228
329
  }
229
330
 
230
331
  /**
231
332
  * Get package version info from registry.
232
333
  *
233
334
  * Resolves through `pnpm view`, which reports a lookup failure the same way it
234
- * reports a missing version. Use {@link getPackagesNeedingManualPublish} where
335
+ * reports a missing version. Use {@link getRegistryPackages} where
235
336
  * absence has to be told apart from an error.
236
337
  *
237
338
  * @param {string} packageName - Name of the package