From 72679d6751fe8e286aa16dfe39e0da95d684f4c9 Mon Sep 17 00:00:00 2001 From: Edward Foyle Date: Thu, 4 Apr 2024 14:34:12 -0700 Subject: [PATCH] its working! --- scripts/components/git_client.ts | 20 ++--- scripts/components/npm_client.ts | 15 +++- .../release_lifecycle_manager.test.ts | 73 +++++++++++++----- .../components/release_lifecycle_manager.ts | 75 ++++++++++++------- .../release_tag_to_name_and_version.ts | 10 +++ scripts/deprecate_release.ts | 11 ++- scripts/restore_release.ts | 8 +- 7 files changed, 151 insertions(+), 61 deletions(-) create mode 100644 scripts/components/release_tag_to_name_and_version.ts diff --git a/scripts/components/git_client.ts b/scripts/components/git_client.ts index 6821929d5d..e7420fc720 100644 --- a/scripts/components/git_client.ts +++ b/scripts/components/git_client.ts @@ -1,7 +1,8 @@ -import { $ as chainableExeca, execa } from 'execa'; +import { $ as chainableExeca } from 'execa'; import { writeFile } from 'fs/promises'; import { EOL } from 'os'; import * as path from 'path'; +import { releaseTagToNameAndVersion } from './release_tag_to_name_and_version.js'; /** * @@ -64,7 +65,7 @@ export class GitClient { commitAllChanges = async (message: string) => { await this.configure(); await this.execWithIO`git add .`; - await this.execWithIO`git commit --message '${message}'`; + await this.execWithIO`git commit --message ${message}`; }; /** @@ -81,7 +82,7 @@ export class GitClient { checkout = async (ref: string, paths: string[] = []) => { const additionalArgs = paths.length > 0 ? ['--', ...paths] : []; - await execa('git', ['checkout', ref, ...additionalArgs]); + await this.execWithIO`git checkout ${ref} ${additionalArgs}`; }; status = async () => { @@ -134,16 +135,11 @@ export class GitClient { await this.validateReleaseCommitHash(releaseCommitHash); const releaseTags = await this.getTagsAtCommit(releaseCommitHash); - /** - * Local function to convert a release tag string to just the package name - * Release tags are formatted as @. For example: @aws-amplify/auth-construct-alpha@0.6.0-beta.8 - */ - const releaseTagToPackageName = (releaseTag: string) => - releaseTag.slice(0, releaseTag.lastIndexOf('@')); - // create a set of just the package names (strip off the version suffix) associated with this release commit const packageNamesRemaining = new Set( - releaseTags.map(releaseTagToPackageName) + releaseTags + .map(releaseTagToNameAndVersion) + .map((nameAndVersion) => nameAndVersion.packageName) ); // initialize the release commit cursor to the commit of the release before the input releaseCommitHash @@ -162,7 +158,7 @@ export class GitClient { releaseCommitCursor ); releaseTagsAtCursor.forEach((releaseTag) => { - const packageName = releaseTagToPackageName(releaseTag); + const { packageName } = releaseTagToNameAndVersion(releaseTag); if (packageNamesRemaining.has(packageName)) { // this means we've found the previous version of "packageNameRemaining" that was released in releaseCommitHash // so we add it to the return list and remove it from the search set diff --git a/scripts/components/npm_client.ts b/scripts/components/npm_client.ts index 50d72cce3b..2ffbf4441d 100644 --- a/scripts/components/npm_client.ts +++ b/scripts/components/npm_client.ts @@ -2,6 +2,17 @@ import { $ as chainableExeca } from 'execa'; import { writeFile } from 'fs/promises'; import { EOL } from 'os'; +/** + * Type for the response of `npm show --json` + * We can add to this as we need to access additional config in a type-safe way + */ +export type PackageInfo = { + // we have to match the output payload of npm show + // eslint-disable-next-line @typescript-eslint/naming-convention + 'dist-tags': Record; + deprecated?: string; +}; + /** * */ @@ -43,10 +54,10 @@ export class NpmClient { .execWithIO`npm dist-tag add ${packageVersionSpecifier} ${distTag}`; }; - getPackageVersionInfo = async (packageVersionSpecifier: string) => { + getPackageInfo = async (packageVersionSpecifier: string) => { const { stdout: jsonString } = await this .exec`npm show ${packageVersionSpecifier} --json`; - return JSON.parse(jsonString) as Record; + return JSON.parse(jsonString) as PackageInfo; }; init = async () => { diff --git a/scripts/components/release_lifecycle_manager.test.ts b/scripts/components/release_lifecycle_manager.test.ts index a845a34e1d..44b2d8b308 100644 --- a/scripts/components/release_lifecycle_manager.test.ts +++ b/scripts/components/release_lifecycle_manager.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, it } from 'node:test'; +import { beforeEach, describe, it, mock } from 'node:test'; import { mkdir, writeFile } from 'fs/promises'; import { randomUUID } from 'crypto'; import { EOL, tmpdir } from 'os'; @@ -12,6 +12,8 @@ import { } from './package-json/package_json.js'; import { runVersion } from '../version_runner.js'; import { runPublish } from '../publish_runner.js'; +import { ReleaseLifecycleManager } from './release_lifecycle_manager.js'; +import { GithubClient } from './github_client.js'; /** * This test suite is more of an integration test than a unit test. @@ -21,6 +23,11 @@ import { runPublish } from '../publish_runner.js'; * Since all of these tests are sharing the same git tree, we're running in serial to avoid conflicts (mostly around duplicate tag names) */ void describe('ReleaseLifecycleManager', async () => { + let gitClient: GitClient; + let npmClient: NpmClient; + + let cantaloupePackageName: string; + let platypusPackageName: string; // before(async () => { // await import('../start_npm_proxy.js'); // }); @@ -29,6 +36,20 @@ void describe('ReleaseLifecycleManager', async () => { // await import('../stop_npm_proxy.js'); // }); + /** + * This setup initializes a "sandbox" git repo that has a js mono repo with 2 packages, cantaloupe and platypus + * It seeds the local npm proxy with a few releases of these packages. + * When its done, the state of the git refs npm dist-tags should be as follows: + * + * minor bump of cantaloupe only ● <- HEAD, cantaloupe@1.3.0, cantaloupe@latest + * | + * minor bump of cantaloupe and platypus ● <- cantaloupe@1.2.0, platypus@1.2.0, platypus@latest + * | + * minor bump of cantaloupe and platypus ● <- cantaloupe@1.1.0, platypus@1.1.0 + * | + * initial commit ● <- cantaloupe@1.0.0, platypus@1.0.0 + * + */ beforeEach(async ({ name: testName }) => { // create temp dir const shortId = randomUUID().split('-')[0]; @@ -40,8 +61,8 @@ void describe('ReleaseLifecycleManager', async () => { await mkdir(testWorkingDir); console.log(testWorkingDir); - const gitClient = new GitClient(testWorkingDir); - const npmClient = new NpmClient(null, testWorkingDir); + gitClient = new GitClient(testWorkingDir); + npmClient = new NpmClient(null, testWorkingDir); const $ = chainableExeca({ stdio: 'inherit', cwd: testWorkingDir }); const runVersionInTestDir = () => runVersion([], testWorkingDir); @@ -49,22 +70,22 @@ void describe('ReleaseLifecycleManager', async () => { runPublish({ useLocalRegistry: true }, testWorkingDir); // converting to lowercase because npm init creates packages with all lowercase - const packageABaseName = - `${testNameNormalized}-package-a-${shortId}`.toLocaleLowerCase(); - const packageBBaseName = - `${testNameNormalized}-package-b-${shortId}`.toLocaleLowerCase(); + cantaloupePackageName = + `${testNameNormalized}-cantaloupe-${shortId}`.toLocaleLowerCase(); + platypusPackageName = + `${testNameNormalized}-platypus-${shortId}`.toLocaleLowerCase(); await gitClient.init(); await npmClient.init(); - await npmClient.initWorkspacePackage(packageABaseName); + await npmClient.initWorkspacePackage(cantaloupePackageName); await setPackageToPublic( - path.join(testWorkingDir, 'packages', packageABaseName) + path.join(testWorkingDir, 'packages', cantaloupePackageName) ); - await npmClient.initWorkspacePackage(packageBBaseName); + await npmClient.initWorkspacePackage(platypusPackageName); await setPackageToPublic( - path.join(testWorkingDir, 'packages', packageBBaseName) + path.join(testWorkingDir, 'packages', platypusPackageName) ); await npmClient.install(['@changesets/cli']); @@ -76,32 +97,50 @@ void describe('ReleaseLifecycleManager', async () => { await commitVersionBumpChangeset( testWorkingDir, gitClient, - [packageABaseName, packageBBaseName], + [cantaloupePackageName, platypusPackageName], 'minor' ); await runVersionInTestDir(); + await gitClient.commitAllChanges('Version Packages (first release)'); await runPublishInTestDir(); await commitVersionBumpChangeset( testWorkingDir, gitClient, - [packageABaseName, packageBBaseName], + [cantaloupePackageName, platypusPackageName], 'minor' ); await runVersionInTestDir(); + await gitClient.commitAllChanges('Version Packages (second release)'); await runPublishInTestDir(); await commitVersionBumpChangeset( testWorkingDir, gitClient, - [packageABaseName], + [cantaloupePackageName], 'minor' ); await runVersionInTestDir(); + await gitClient.commitAllChanges('Version Packages (third release)'); await runPublishInTestDir(); }); - void it('dummy test', () => {}); + void it('dummy test', async () => { + const githubClient = new GithubClient('garbage'); + mock.method(githubClient, 'createPr', async () => ({ prUrl: 'testPrUrl' })); + mock.method(gitClient, 'push', async () => {}); + const releaseLifecycleManager = new ReleaseLifecycleManager( + 'HEAD', + githubClient, + gitClient, + npmClient + ); + await releaseLifecycleManager.deprecateRelease('cantaloupe is rotten'); + + // expect latest of cantaloupe to point back to 1.2.0 and 1.3.0 to be marked deprecated + // platypus should not be modified + // const await npmClient.getPackageInfo(cantaloupePackageName); + }); }); const setPackageToPublic = async (packagePath: string) => { @@ -118,9 +157,7 @@ const commitVersionBumpChangeset = async ( packageNames: string[], bump: VersionBump ) => { - const message = `${bump} version bump for packages ${packageNames.join( - ', ' - )}`; + const message = `${bump} bump for ${packageNames.join(', ')}`; await writeFile( path.join(projectPath, '.changeset', `${randomUUID()}.md`), getChangesetContent(packageNames, bump, message) diff --git a/scripts/components/release_lifecycle_manager.ts b/scripts/components/release_lifecycle_manager.ts index 0217b3c468..4708ba84e9 100644 --- a/scripts/components/release_lifecycle_manager.ts +++ b/scripts/components/release_lifecycle_manager.ts @@ -3,6 +3,13 @@ import { GitClient } from './git_client.js'; import { NpmClient } from './npm_client.js'; import { getDistTagFromReleaseTag } from './get_dist_tag_from_release_tag.js'; import { GithubClient } from './github_client.js'; +import { releaseTagToNameAndVersion } from './release_tag_to_name_and_version.js'; + +type DeprecationAction = { + releaseTagToDeprecate: string; + previousReleaseTag: string; + distTagsToMove: string[]; +}; /** * @@ -43,13 +50,37 @@ export class ReleaseLifecycleManager { // if this deprecation is starting from HEAD, we are deprecating the most recent release and need to point dist-tags back to their previous state // if we are deprecating a past release, then the dist-tags have moved on to newer versions and we do not need to reset them - const releaseTagsToRestoreDistTagPointers = + const previousReleaseTags = this.gitRefToStartReleaseSearchFrom === 'HEAD' ? await this.gitClient.getPreviousReleaseTags( releaseCommitHashToDeprecate ) : []; + const deprecationActions: DeprecationAction[] = []; + + for (const releaseTag of releaseTagsToDeprecate) { + const { version: versionBeingDeprecated, packageName } = + releaseTagToNameAndVersion(releaseTag); + const deprecationAction: DeprecationAction = { + releaseTagToDeprecate: releaseTag, + previousReleaseTag: previousReleaseTags.find((prevTag) => + prevTag.includes(packageName) + )!, // this is safe because gitClient.getPreviousReleaseTags already ensures that we have found a previous version for all packages + distTagsToMove: [], + }; + const { ['dist-tags']: distTags } = await this.npmClient.getPackageInfo( + releaseTag + ); + Object.entries(distTags).forEach(([tagName, versionAtTag]) => { + // if this tag points to the version being deprecated, add that tag to the list of tags to move to the previous version + if (versionAtTag === versionBeingDeprecated) { + deprecationAction.distTagsToMove.push(tagName); + } + }); + deprecationActions.push(deprecationAction); + } + // first create the changeset revert PR // this PR restores the changeset files that were part of the release but does NOT revert the package.json and changelog changes const prBranch = `revert_changeset/${releaseCommitHashToDeprecate}`; @@ -74,35 +105,28 @@ export class ReleaseLifecycleManager { console.log(`Created deprecation PR at ${prUrl}`); - if (releaseTagsToRestoreDistTagPointers.length > 0) { - console.log( - `Pointing dist-tags back to previous versions:${EOL}${releaseTagsToRestoreDistTagPointers.join( - EOL - )}${EOL}` - ); - } - - console.log( - `Deprecating package versions:${EOL}${releaseTagsToDeprecate.join( - EOL - )}${EOL}` - ); + console.log(JSON.stringify(deprecationActions, null, 2)); // if anything fails before this point, we haven't actually modified anything on NPM yet. // now we actually update the npm dist tags and mark the packages as deprecated - for (const releaseTag of releaseTagsToRestoreDistTagPointers) { - const distTag = getDistTagFromReleaseTag(releaseTag); - console.log( - `Restoring dist tag "${distTag}" to package version ${releaseTag}` + for (const { + distTagsToMove, + previousReleaseTag, + releaseTagToDeprecate, + } of deprecationActions) { + for (const distTagToMove of distTagsToMove) { + console.log( + `Restoring dist tag "${distTagToMove}" to package version ${previousReleaseTag}` + ); + await this.npmClient.setDistTag(previousReleaseTag, distTagToMove); + console.log(`Done!${EOL}`); + } + console.log(`Deprecating package version ${releaseTagToDeprecate}`); + await this.npmClient.deprecatePackage( + releaseTagToDeprecate, + deprecationMessage ); - await this.npmClient.setDistTag(releaseTag, distTag); - console.log(`Done!${EOL}`); - } - - for (const releaseTag of releaseTagsToDeprecate) { - console.log(`Deprecating package version ${releaseTag}`); - await this.npmClient.deprecatePackage(releaseTag, deprecationMessage); console.log(`Done!${EOL}`); } }; @@ -197,6 +221,5 @@ export class ReleaseLifecycleManager { The release deprecation workflow requires a clean working tree to create the rollback PR. `); } - await this.npmClient.configureNpmRc(); }; } diff --git a/scripts/components/release_tag_to_name_and_version.ts b/scripts/components/release_tag_to_name_and_version.ts new file mode 100644 index 0000000000..d3bcdab431 --- /dev/null +++ b/scripts/components/release_tag_to_name_and_version.ts @@ -0,0 +1,10 @@ +/** + * Splits a release tag of @ into its constituent parts + */ +export const releaseTagToNameAndVersion = (releaseTag: string) => { + const indexOfLastAt = releaseTag.lastIndexOf('@'); + return { + packageName: releaseTag.slice(0, indexOfLastAt), + version: releaseTag.slice(indexOfLastAt + 1), + }; +}; diff --git a/scripts/deprecate_release.ts b/scripts/deprecate_release.ts index 0f164ea12d..64ca92e9f0 100644 --- a/scripts/deprecate_release.ts +++ b/scripts/deprecate_release.ts @@ -10,7 +10,8 @@ const deprecationMessage = getInput('deprecationMessage', { const searchForReleaseStartingFrom = getInput('searchForReleaseStartingFrom', { required: true, }); -const useNpmRegistry = getInput('useNpmRegistry', { required: true }); +const useNpmRegistry = + getInput('useNpmRegistry', { required: true }) === 'true'; if (useNpmRegistry) { console.log( @@ -22,11 +23,17 @@ if (useNpmRegistry) { ); } +const npmClient = new NpmClient( + useNpmRegistry ? loadNpmTokenFromEnvVar() : null +); + +await npmClient.configureNpmRc(); + const releaseLifecycleManager = new ReleaseLifecycleManager( searchForReleaseStartingFrom, new GithubClient(), new GitClient(), - new NpmClient(useNpmRegistry ? loadNpmTokenFromEnvVar() : null) + npmClient ); try { diff --git a/scripts/restore_release.ts b/scripts/restore_release.ts index 1a515d1a91..988e0db37c 100644 --- a/scripts/restore_release.ts +++ b/scripts/restore_release.ts @@ -19,11 +19,17 @@ if (useNpmRegistry) { ); } +const npmClient = new NpmClient( + useNpmRegistry ? loadNpmTokenFromEnvVar() : null +); + +await npmClient.configureNpmRc(); + const releaseLifecycleManager = new ReleaseLifecycleManager( searchForReleaseStartingFrom, new GithubClient(), new GitClient(), - new NpmClient(useNpmRegistry ? loadNpmTokenFromEnvVar() : null) + npmClient ); try {