diff --git a/scripts/components/dist_tag_mover.ts b/scripts/components/dist_tag_mover.ts new file mode 100644 index 0000000000..ed753dfa41 --- /dev/null +++ b/scripts/components/dist_tag_mover.ts @@ -0,0 +1,69 @@ +import { EOL } from 'os'; +import { NpmClient } from './npm_client.js'; +import { releaseTagToNameAndVersion } from './release_tag_to_name_and_version.js'; + +type DistTagMoveAction = { + /** + * An NPM dist-tag + */ + distTag: string; + /** + * This is a string of the form @ + */ + releaseTag: string; +}; + +/** + * Handles moving npm dist-tags from one package version to another + */ +export class DistTagMover { + /** + * Initialize with an npmClient + */ + constructor(private readonly npmClient: NpmClient) {} + + /** + * Given a list of sourceReleaseTags and destReleaseTags, + * any npm dist-tags that are pointing to a sourceReleaseTag will be moved to point to the corresponding destReleaseTag + */ + moveDistTags = async ( + sourceReleaseTags: string[], + destReleaseTags: string[] + ) => { + const moveActions: DistTagMoveAction[] = []; + + for (const sourceReleaseTag of sourceReleaseTags) { + const { packageName, version: sourceVersion } = + releaseTagToNameAndVersion(sourceReleaseTag); + + const { 'dist-tags': distTags } = await this.npmClient.getPackageInfo( + sourceReleaseTag + ); + + Object.entries(distTags).forEach(([tagName, versionAtTag]) => { + if (versionAtTag !== sourceVersion) { + return; + } + const destReleaseTag = destReleaseTags.find((releaseTag) => + releaseTag.includes(packageName) + ); + if (!destReleaseTag) { + // this should never happen because of the upstream logic that resolves the corresponding versions + throw new Error( + `No corresponding destination release tag found for ${sourceReleaseTag}` + ); + } + moveActions.push({ + releaseTag: destReleaseTag, + distTag: tagName, + }); + }); + } + + for (const { distTag, releaseTag } of moveActions) { + console.log(`Moving dist tag "${distTag}" to release tag ${releaseTag}`); + await this.npmClient.setDistTag(releaseTag, distTag); + console.log(`Done!${EOL}`); + } + }; +} diff --git a/scripts/components/git_client.ts b/scripts/components/git_client.ts index e7420fc720..4d49424331 100644 --- a/scripts/components/git_client.ts +++ b/scripts/components/git_client.ts @@ -37,11 +37,16 @@ export class GitClient { }; /** - * Returns true if the git tree is clean and false if it is dirty + * Throws if there are uncommitted changes in the repo */ - isWorkingTreeClean = async () => { + ensureWorkingTreeIsClean = async () => { const { stdout } = await this.exec`git status --porcelain`; - return !stdout.trim(); + const isDirty = stdout.trim(); + if (isDirty) { + throw new Error( + 'Dirty working tree detected. Commit or stash changes to continue.' + ); + } }; getCurrentBranch = async () => { diff --git a/scripts/components/release_deprecator.ts b/scripts/components/release_deprecator.ts new file mode 100644 index 0000000000..c68d3fa2f7 --- /dev/null +++ b/scripts/components/release_deprecator.ts @@ -0,0 +1,91 @@ +import { EOL } from 'os'; +import { GitClient } from './git_client.js'; +import { NpmClient } from './npm_client.js'; +import { GithubClient } from './github_client.js'; +import { DistTagMover } from './dist_tag_mover.js'; + +/** + * + */ +export class ReleaseDeprecator { + /** + * Initialize with deprecation config and necessary clients + */ + constructor( + private readonly gitRefToStartReleaseSearchFrom: string, + private readonly deprecationMessage: string, + private readonly githubClient: GithubClient, + private readonly gitClient: GitClient, + private readonly npmClient: NpmClient, + private readonly distTagMover: DistTagMover + ) {} + + /** + * This method deprecates a set of package versions that were released by a single release commit. + * + * The steps that it takes are + * 1. Given a starting commit, find the most recent release commit (this could be the commit itself) + * 2. Find the git tags associated with that commit. These are the package versions that need to be deprecated + * 3. Find the git tags associated with the previous versions of the packages that are being deprecated. These are the package versions that need to be marked as "latest" (or whatever the dist-tag for the release is) + * 5. Creates a rollback PR that resets the .changeset directory to its state before the release + * 6. Resets the dist-tags to the previous package versions + * 7. Marks the current package versions as deprecated + */ + deprecateRelease = async () => { + await this.gitClient.ensureWorkingTreeIsClean(); + + const releaseCommitHashToDeprecate = + await this.gitClient.getNearestReleaseCommit( + this.gitRefToStartReleaseSearchFrom + ); + + const releaseTagsToDeprecate = await this.gitClient.getTagsAtCommit( + releaseCommitHashToDeprecate + ); + + const previousReleaseTags = await this.gitClient.getPreviousReleaseTags( + releaseCommitHashToDeprecate + ); + + // 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}`; + + await this.gitClient.switchToBranch(prBranch); + await this.gitClient.checkout(`${releaseCommitHashToDeprecate}^`, [ + '.changeset', + ]); + await this.gitClient.status(); + await this.gitClient.commitAllChanges( + `Reverting updates to the .changeset directory made by release commit ${releaseCommitHashToDeprecate}` + ); + await this.gitClient.push({ force: true }); + + console.log(EOL); + + const { prUrl } = await this.githubClient.createPr({ + head: prBranch, + title: `Deprecate release ${releaseCommitHashToDeprecate}`, + body: `Reverting updates to the .changeset directory made by release commit ${releaseCommitHashToDeprecate}`, + }); + + console.log(`Created deprecation PR at ${prUrl}`); + + // 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 + + await this.distTagMover.moveDistTags( + releaseTagsToDeprecate, + previousReleaseTags + ); + + for (const releaseTag of releaseTagsToDeprecate) { + console.log(`Deprecating package version ${releaseTag}`); + await this.npmClient.deprecatePackage( + releaseTag, + this.deprecationMessage + ); + console.log(`Done!${EOL}`); + } + }; +} diff --git a/scripts/components/release_lifecycle_manager.test.ts b/scripts/components/release_lifecycle.test.ts similarity index 93% rename from scripts/components/release_lifecycle_manager.test.ts rename to scripts/components/release_lifecycle.test.ts index 70c125f973..9f696a7f1d 100644 --- a/scripts/components/release_lifecycle_manager.test.ts +++ b/scripts/components/release_lifecycle.test.ts @@ -12,16 +12,15 @@ 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'; import assert from 'node:assert'; +import { ReleaseDeprecator } from './release_deprecator.js'; +import { DistTagMover } from './dist_tag_mover.js'; /** * This test suite is more of an integration test than a unit test. * It uses the real file system and git repo but mocks the GitHub API client * It spins up verdaccio to test updating package metadata locally - * - * 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; @@ -29,6 +28,8 @@ void describe('ReleaseLifecycleManager', async () => { let cantaloupePackageName: string; let platypusPackageName: string; + + // TODO uncomment before merging // before(async () => { // await import('../start_npm_proxy.js'); // }); @@ -136,13 +137,15 @@ void describe('ReleaseLifecycleManager', async () => { const githubClient = new GithubClient('garbage'); mock.method(githubClient, 'createPr', async () => ({ prUrl: 'testPrUrl' })); mock.method(gitClient, 'push', async () => {}); - const releaseLifecycleManager = new ReleaseLifecycleManager( + const releaseDeprecator = new ReleaseDeprecator( 'HEAD', + 'the cantaloupe is rotten', githubClient, gitClient, - npmClient + npmClient, + new DistTagMover(npmClient) ); - await releaseLifecycleManager.deprecateRelease('cantaloupe is rotten'); + await releaseDeprecator.deprecateRelease(); // switch back to the original branch await gitClient.switchToBranch('main'); @@ -151,7 +154,7 @@ void describe('ReleaseLifecycleManager', async () => { const { 'dist-tags': distTags, deprecated } = await npmClient.getPackageInfo(`${cantaloupePackageName}@1.3.0`); assert.equal(distTags.latest, '1.2.0'); - assert.equal(deprecated, 'cantaloupe is rotten'); + assert.equal(deprecated, 'the cantaloupe is rotten'); }); }); diff --git a/scripts/components/release_lifecycle_manager.ts b/scripts/components/release_lifecycle_manager.ts deleted file mode 100644 index d4d499b390..0000000000 --- a/scripts/components/release_lifecycle_manager.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { EOL } from 'os'; -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[]; -}; - -/** - * - */ -export class ReleaseLifecycleManager { - /** - * Initialize with deprecation config and necessary clients - */ - constructor( - private readonly gitRefToStartReleaseSearchFrom: string, - private readonly githubClient: GithubClient, - private readonly gitClient: GitClient, - private readonly npmClient: NpmClient - ) {} - - /** - * This method deprecates a set of package versions that were released by a single release commit. - * - * The steps that it takes are - * 1. Given a starting commit, find the most recent release commit (this could be the commit itself) - * 2. Find the git tags associated with that commit. These are the package versions that need to be deprecated - * 3. Find the git tags associated with the previous versions of the packages that are being deprecated. These are the package versions that need to be marked as "latest" (or whatever the dist-tag for the release is) - * 5. Creates a rollback PR that resets the .changeset directory to its state before the release - * 6. Resets the dist-tags to the previous package versions - * 7. Marks the current package versions as deprecated - */ - deprecateRelease = async (deprecationMessage: string) => { - await this.preFlightChecks(); - - const releaseCommitHashToDeprecate = - await this.gitClient.getNearestReleaseCommit( - this.gitRefToStartReleaseSearchFrom - ); - - const releaseTagsToDeprecate = await this.gitClient.getTagsAtCommit( - releaseCommitHashToDeprecate - ); - - // 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 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}`; - - await this.gitClient.switchToBranch(prBranch); - await this.gitClient.checkout(`${releaseCommitHashToDeprecate}^`, [ - '.changeset', - ]); - await this.gitClient.status(); - await this.gitClient.commitAllChanges( - `Reverting updates to the .changeset directory made by release commit ${releaseCommitHashToDeprecate}` - ); - await this.gitClient.push({ force: true }); - - console.log(EOL); - - const { prUrl } = await this.githubClient.createPr({ - head: prBranch, - title: `Deprecate release ${releaseCommitHashToDeprecate}`, - body: `Reverting updates to the .changeset directory made by release commit ${releaseCommitHashToDeprecate}`, - }); - - console.log(`Created deprecation PR at ${prUrl}`); - - 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 { - 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 - ); - console.log(`Done!${EOL}`); - } - }; - - /** - * This method is the "undo" button for the deprecateRelease method. - * - * There are times when we may deprecate a release and want to restore it at a later time. - * For example, if a new release exposes a service bug, we may deprecate the release, patch the service bug, - * then restore the release once it works with the fixed service. - * - * Running this method without running the deprecateRelease method is effectively a no-op (because the current release is already "un-deprecated") - */ - restoreRelease = async () => { - await this.preFlightChecks(); - const searchStartCommit = - this.gitRefToStartReleaseSearchFrom.length === 0 - ? 'HEAD' - : this.gitRefToStartReleaseSearchFrom; - - await this.gitClient.fetchTags(); - - const releaseCommitHashToRestore = - await this.gitClient.getNearestReleaseCommit(searchStartCommit); - - const releaseTagsToUnDeprecate = await this.gitClient.getTagsAtCommit( - releaseCommitHashToRestore - ); - - // if we are restoring the most recent release on the branch, then we need to restore dist-tags as well. - // if we are restoring a past release, then the dist-tags have already moved on to newer versions and we do not need to reset them - const releaseTagsToRestoreDistTagPointers = - searchStartCommit === 'HEAD' ? releaseTagsToUnDeprecate : []; - - // first create the changeset restore 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 = `restore_changeset/${releaseCommitHashToRestore}`; - - await this.gitClient.switchToBranch(prBranch); - await this.gitClient.checkout(releaseCommitHashToRestore, ['.changeset']); - await this.gitClient.status(); - await this.gitClient.commitAllChanges( - `Restoring updates to the .changeset directory made by release commit ${releaseCommitHashToRestore}` - ); - await this.gitClient.push({ force: true }); - - const { prUrl } = await this.githubClient.createPr({ - head: prBranch, - title: `Restore release ${releaseCommitHashToRestore}`, - body: `Restoring updates to the .changeset directory made by release commit ${releaseCommitHashToRestore}`, - }); - - console.log(`Created release restoration PR at ${prUrl}`); - - if (releaseTagsToRestoreDistTagPointers.length > 0) { - console.log( - `Restoring dist-tags to package versions:${EOL}${releaseTagsToRestoreDistTagPointers.join( - EOL - )}${EOL}` - ); - } - - console.log( - `Un-deprecating package versions:${EOL}${releaseTagsToUnDeprecate.join( - EOL - )}${EOL}` - ); - - // 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 un-deprecated - - for (const releaseTag of releaseTagsToRestoreDistTagPointers) { - const distTag = getDistTagFromReleaseTag(releaseTag); - console.log( - `Restoring dist tag "${distTag}" to package version ${releaseTag}` - ); - await this.npmClient.setDistTag(releaseTag, distTag); - console.log(`Done!${EOL}`); - } - - for (const releaseTag of releaseTagsToUnDeprecate) { - console.log(`Un-deprecating package version ${releaseTag}`); - await this.npmClient.unDeprecatePackage(releaseTag); - console.log(`Done!${EOL}`); - } - }; - - private preFlightChecks = async () => { - if (!(await this.gitClient.isWorkingTreeClean())) { - throw new Error(` - Dirty working tree detected. - The release deprecation workflow requires a clean working tree to create the rollback PR. - `); - } - }; -} diff --git a/scripts/components/release_restorer.ts b/scripts/components/release_restorer.ts new file mode 100644 index 0000000000..65d169ea4e --- /dev/null +++ b/scripts/components/release_restorer.ts @@ -0,0 +1,81 @@ +import { EOL } from 'os'; +import { GitClient } from './git_client.js'; +import { NpmClient } from './npm_client.js'; +import { GithubClient } from './github_client.js'; +import { DistTagMover } from './dist_tag_mover.js'; + +/** + * + */ +export class ReleaseRestorer { + /** + * Initialize with deprecation config and necessary clients + */ + constructor( + private readonly gitRefToStartReleaseSearchFrom: string, + private readonly githubClient: GithubClient, + private readonly gitClient: GitClient, + private readonly npmClient: NpmClient, + private readonly distTagMover: DistTagMover + ) {} + + /** + * This method is the "undo" button for the ReleaseDeprecator. + * + * There are times when we may deprecate a release and want to restore it at a later time. + * For example, if a new release exposes a service bug, we may deprecate the release, patch the service bug, + * then restore the release once it works with the fixed service. + * + * Running this method without running the deprecateRelease method is effectively a no-op (because the current release is already "un-deprecated") + */ + restoreRelease = async () => { + await this.gitClient.ensureWorkingTreeIsClean(); + + const releaseCommitHashToRestore = + await this.gitClient.getNearestReleaseCommit( + this.gitRefToStartReleaseSearchFrom + ); + + const releaseTagsToRestore = await this.gitClient.getTagsAtCommit( + releaseCommitHashToRestore + ); + + const previousReleaseTags = await this.gitClient.getPreviousReleaseTags( + releaseCommitHashToRestore + ); + + // first create the changeset restore 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 = `restore_changeset/${releaseCommitHashToRestore}`; + + await this.gitClient.switchToBranch(prBranch); + await this.gitClient.checkout(releaseCommitHashToRestore, ['.changeset']); + await this.gitClient.status(); + await this.gitClient.commitAllChanges( + `Restoring updates to the .changeset directory made by release commit ${releaseCommitHashToRestore}` + ); + await this.gitClient.push({ force: true }); + + const { prUrl } = await this.githubClient.createPr({ + head: prBranch, + title: `Restore release ${releaseCommitHashToRestore}`, + body: `Restoring updates to the .changeset directory made by release commit ${releaseCommitHashToRestore}`, + }); + + console.log(`Created release restoration PR at ${prUrl}`); + + // 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 un-deprecated + + await this.distTagMover.moveDistTags( + previousReleaseTags, + releaseTagsToRestore + ); + + for (const releaseTag of releaseTagsToRestore) { + console.log(`Un-deprecating package version ${releaseTag}`); + await this.npmClient.unDeprecatePackage(releaseTag); + console.log(`Done!${EOL}`); + } + }; +} diff --git a/scripts/deprecate_release.ts b/scripts/deprecate_release.ts index 64ca92e9f0..3a6739dd9c 100644 --- a/scripts/deprecate_release.ts +++ b/scripts/deprecate_release.ts @@ -1,8 +1,9 @@ import { getInput } from '@actions/core'; -import { ReleaseLifecycleManager } from './components/release_lifecycle_manager.js'; import { GithubClient } from './components/github_client.js'; import { GitClient } from './components/git_client.js'; import { NpmClient, loadNpmTokenFromEnvVar } from './components/npm_client.js'; +import { ReleaseDeprecator } from './components/release_deprecator.js'; +import { DistTagMover } from './components/dist_tag_mover.js'; const deprecationMessage = getInput('deprecationMessage', { required: true, @@ -29,15 +30,17 @@ const npmClient = new NpmClient( await npmClient.configureNpmRc(); -const releaseLifecycleManager = new ReleaseLifecycleManager( +const releaseDeprecator = new ReleaseDeprecator( searchForReleaseStartingFrom, + deprecationMessage, new GithubClient(), new GitClient(), - npmClient + npmClient, + new DistTagMover(npmClient) ); try { - await releaseLifecycleManager.deprecateRelease(deprecationMessage); + await releaseDeprecator.deprecateRelease(); } catch (err) { console.error(err); process.exitCode = 1; diff --git a/scripts/publish_local.ts b/scripts/publish_local.ts index 7baee6c3f3..2ad16e2998 100644 --- a/scripts/publish_local.ts +++ b/scripts/publish_local.ts @@ -11,11 +11,7 @@ const keepGitDiff = runArgs.find((arg) => arg === '--keepGitDiff'); const gitClient = new GitClient(); if (!keepGitDiff) { - if (!(await gitClient.isWorkingTreeClean())) { - throw new Error( - `Detected a dirty working tree. Commit or stash changes before publishing a snapshot` - ); - } + await gitClient.ensureWorkingTreeIsClean(); } // this command will write staged changesets into changelog files and update versions diff --git a/scripts/restore_release.ts b/scripts/restore_release.ts index 988e0db37c..4f7aac2c65 100644 --- a/scripts/restore_release.ts +++ b/scripts/restore_release.ts @@ -1,8 +1,9 @@ import { getInput } from '@actions/core'; -import { ReleaseLifecycleManager } from './components/release_lifecycle_manager.js'; +import { ReleaseRestorer } from './components/release_restorer.js'; import { GitClient } from './components/git_client.js'; import { GithubClient } from './components/github_client.js'; import { NpmClient, loadNpmTokenFromEnvVar } from './components/npm_client.js'; +import { DistTagMover } from './components/dist_tag_mover.js'; const searchForReleaseStartingFrom = getInput('searchForReleaseStartingFrom', { required: true, @@ -25,15 +26,16 @@ const npmClient = new NpmClient( await npmClient.configureNpmRc(); -const releaseLifecycleManager = new ReleaseLifecycleManager( +const releaseRestorer = new ReleaseRestorer( searchForReleaseStartingFrom, new GithubClient(), new GitClient(), - npmClient + npmClient, + new DistTagMover(npmClient) ); try { - await releaseLifecycleManager.restoreRelease(); + await releaseRestorer.restoreRelease(); } catch (err) { console.error(err); process.exitCode = 1;