Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Upgrade Tool] Version Targeting Strategy V2 #18906

Merged
merged 5 commits into from
Nov 27, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
46 changes: 34 additions & 12 deletions packages/utils/upgrade/src/__tests__/core/version.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import semver from 'semver';

import {
isVersionRelease,
isLatestVersion,
isVersion,
isSemVer,
createSemverRange,
VersionRelease,
formatSemVer,
} from '../../core';

describe('Version', () => {
Expand All @@ -13,6 +15,8 @@ describe('Version', () => {
['5.0', false],
['5.0.0', false],
['5.0.0.0', false],
['next', true],
['current', true],
['latest', true],
['major', true],
['minor', true],
Expand All @@ -30,6 +34,8 @@ describe('Version', () => {
['5.0', false],
['5.0.0', false],
['5.0.0.0', false],
['next', false],
['current', false],
['latest', true],
['major', false],
['minor', false],
Expand All @@ -47,6 +53,8 @@ describe('Version', () => {
['5.0', false],
['5.0.0', true],
['5.0.0.0', false],
['next', true],
['current', true],
['latest', true],
['major', true],
['minor', true],
Expand Down Expand Up @@ -81,27 +89,41 @@ describe('Version', () => {
const from = '4.0.0';
const to = '6.0.0';

const range = createSemverRange({ from, to });
const range = createSemverRange(`>${from} <=${to}`);

expect(range.test(from)).toBe(false);

expect(range.raw).toStrictEqual(`>${from} <=${to}`);
expect(range.test('5.0.0')).toBe(true);
expect(range.test(to)).toBe(true);
});

test('Create a range to "latest"', () => {
const from = '4.0.0';
const to = VersionRelease.Latest;

const range = createSemverRange({ from, to });
const range = createSemverRange(`>${from}`);

expect(range.test(from)).toBe(false);

expect(range.test('9.0.0')).toBe(true);
});
});

expect(range.raw).toStrictEqual(`>${from}`);
describe('Format SemVer', () => {
const version = new semver.SemVer('4.15.5');

test('Format to <major>', () => {
const formatted = formatSemVer(version, 'x');
expect(formatted).toBe('4');
});

test('Throw on invalid boundaries', () => {
const from = '6.0.0';
const to = '4.0.0';
test('Format to <major>.<minor>', () => {
const formatted = formatSemVer(version, 'x.x');
expect(formatted).toBe('4.15');
});

expect(() => createSemverRange({ from, to })).toThrowError(
`Upper boundary (${to}) must be greater than lower boundary (${from})`
);
test('Format to <major>.<minor>.<patch>', () => {
const formatted = formatSemVer(version, 'x.x.x');
expect(formatted).toBe('4.15.5');
});
});
});
1 change: 1 addition & 0 deletions packages/utils/upgrade/src/cli/commands/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const upgrade = async (options: CLIOptions) => {
dryRun: options.dryRun,
cwd: options.projectPath,
target: options.target,
exact: options.exact,
});
} catch (err) {
handleError(err);
Expand Down
15 changes: 10 additions & 5 deletions packages/utils/upgrade/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,28 @@ import { isVersion, VersionRelease } from '../core';

import type { CLIOptions } from '../types';

const ALLOWED_TARGETS =
'Allowed choices are major, minor, patch, latest, or a specific version number in the form "x.x.x"';
const RELEASES_CHOICES = Object.values(VersionRelease).join(', ');
const ALLOWED_TARGETS = `Allowed choices are ${RELEASES_CHOICES} or a specific version number in the form "x.x.x"`;

program
.description('Upgrade to the desired version')
.option('-p, --project-path <project-path>', 'Path to the Strapi project')
.addOption(
new Option('-t, --target <target>', `Specify which version to upgrade to. ${ALLOWED_TARGETS}`)
.default(VersionRelease.Patch)
new Option('-t, --target <target>', `Specify which version to upgrade to ${ALLOWED_TARGETS}`)
.default(VersionRelease.Next)
.argParser((target) => {
assert(isVersion(target), new InvalidOptionArgumentError(ALLOWED_TARGETS));
return target;
})
)
.option(
'-e --exact',
'If <target> is in the form "x.x.x", only run the upgrade for this version',
false
)
.option('-n, --dry-run', 'Simulate the upgrade without updating any files', false)
.option('-d, --debug', 'Get more logs in debug mode', false)
.option('-s, --silent', "Don't log anything", false)
.option('-p, --project-path <project-path>', 'Path to the Strapi project')
.action(async () => {
const options = program.opts<CLIOptions>();

Expand Down
31 changes: 14 additions & 17 deletions packages/utils/upgrade/src/core/transforms-loader.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import * as semver from 'semver';
import * as path from 'node:path';
import assert from 'node:assert';
import { readdirSync, statSync } from 'node:fs';
import { readdirSync, statSync, existsSync } from 'node:fs';

import { createSemverRange, isVersionRelease } from './version';
import { isVersionRelease } from './version';
import * as f from './format';

import type { Logger, Version, VersionRange, SemVer } from '.';
import type { Logger, Version, SemVer } from '.';
import type { TransformFile, TransformFileKind } from '../types';

export interface CreateTransformsLoaderOptions {
dir?: string;
range: VersionRange;
range: semver.Range;
logger: Logger;
}

Expand All @@ -27,7 +27,7 @@ const TRANSFORM_FILE_REGEXP = new RegExp(
export const createTransformsLoader = (options: CreateTransformsLoaderOptions) => {
const { dir = INTERNAL_TRANSFORMS_DIR, range, logger } = options;

const semverRange = createSemverRange(range);
assert(existsSync(dir), `Invalid transforms directory provided "${dir}"`);

// TODO: Maybe add some more logs regarding what folders are accepted/discarded
const versions = readdirSync(dir)
Expand All @@ -36,19 +36,18 @@ export const createTransformsLoader = (options: CreateTransformsLoaderOptions) =
// Paths should be valid semver
.filter((filePath): filePath is SemVer => semver.valid(filePath) !== null)
// Should satisfy the given range
.filter((filePath) => semverRange.test(filePath))
.filter((filePath) => range.test(filePath))
// Sort versions in ascending order
.sort(semver.compare) as SemVer[];

if (versions.length === 0) {
// TODO: Use custom upgrade errors
throw new Error(`Invalid transforms directory provided "${dir}"`);
}

const fNbFound = f.highlight(versions.length.toString());
const fRange = f.versionRange(semverRange.raw);
const fRange = f.versionRange(range.raw);
const fVersions = versions.map(f.version).join(', ');

if (versions.length === 0) {
throw new Error(`Could not find any upgrade matching the given range (${fRange})`);
}

logger.debug(`Found ${fNbFound} upgrades matching ${fRange} (${fVersions})`);

// Note: We're casting the result as a SemVer since we know there is at least one item in the `versions` array
Expand Down Expand Up @@ -100,15 +99,13 @@ export const createTransformsLoader = (options: CreateTransformsLoaderOptions) =
return transformsPath;
};

const loadRange = (range: VersionRange): TransformFile[] => {
const loadRange = (range: semver.Range): TransformFile[] => {
const paths: TransformFile[] = [];

const semverRange = createSemverRange(range);

logger.debug(`Loading transforms matching ${f.versionRange(semverRange.raw)}`);
logger.debug(`Loading transforms matching ${f.versionRange(range.raw)}`);

for (const version of versions) {
const isInRange = semverRange.test(version);
const isInRange = range.test(version);

if (isInRange) {
const transformsForVersion = load(version);
Expand Down
125 changes: 87 additions & 38 deletions packages/utils/upgrade/src/core/version-parser.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import semver from 'semver';
import assert from 'node:assert';

import { isLatestVersion, isSemVer, isVersionRelease, VersionRelease } from './version';
import {
createSemverRange,
formatSemVer,
isNextVersion,
isSemVer,
isVersionRelease,
VersionRelease,
} from './version';

import type { SemVer, Version } from './version';

export interface VersionParser {
current: string;
setAvailable(versions: SemVer[] | null): VersionParser;
nextMajor(): SemVer | undefined;
nextMinor(): SemVer | undefined;
nextPatch(): SemVer | undefined;
latest(): SemVer | undefined;
current(): SemVer | undefined;
next(): SemVer | undefined;
exact(version: SemVer): SemVer | undefined;
search(version: Version): SemVer | undefined;
}
Expand All @@ -30,10 +37,6 @@ export const createVersionParser: CreateVersionParser = (current) => {
};

return {
get current(): string {
return state.current.raw;
},

setAvailable(versions: SemVer[] | null) {
state.available = versions !== null ? versions.map((v) => new semver.SemVer(v)) : null;

Expand All @@ -56,61 +59,107 @@ export const createVersionParser: CreateVersionParser = (current) => {
return this.search(VersionRelease.Latest);
},

next() {
return this.search(VersionRelease.Next);
},

current() {
return this.search(VersionRelease.Current);
},

exact(version: SemVer) {
return this.search(version);
},

search(version: Version) {
if (!state.available) {
const { current, available } = state;
const currentAsString = current.raw as SemVer;

if (!available) {
return undefined;
}

let versionFilter: (v: semver.SemVer) => boolean = () => false;
let range: semver.Range;

if (isSemVer(version)) {
assert(
state.current.compare(version) === -1,
`The given version should be greater than the current one (${state.current.raw}>${version})`
);
// {current} > {v} AND {v} <= {version}
versionFilter = (v) => v.compare(state.current) === 1 && v.compare(version) <= 0;
range = semver.gt(version, current)
? // If target > current, return a range
createSemverRange(`>${currentAsString} <=${version}`)
: // Else, return an exact match
createSemverRange(`=${version}`);
}

if (isVersionRelease(version)) {
versionFilter = (v) => {
switch (version) {
case VersionRelease.Latest:
// match any version that is greater than the current one
return v.compare(state.current) === 1;
case VersionRelease.Major:
// match any version which major release is greater than the current one
return v.major > state.current.major;
case VersionRelease.Minor:
// match any version which minor release is greater than the current one
return v.minor > state.current.minor;
case VersionRelease.Patch:
// match any version which patch release is greater than the current one
return v.patch > state.current.patch;
default:
throw new Error(`Internal error: Invalid version release found: ${version}`);
switch (version) {
/**
* Only accept the same version as the current one
*/
case VersionRelease.Current: {
range = createSemverRange(`=${currentAsString}`); // take exactly this version
break;
}
};
/**
* Accept any version greater than the current one
*/
case VersionRelease.Latest:
case VersionRelease.Next: {
range = createSemverRange(`>${currentAsString}`);
break;
}
/**
* Accept any version where
* - The overall version is greater than the current one
* - The major version is the same or +1
*/
case VersionRelease.Major: {
const nextMajor = formatSemVer(current.inc('major'), 'x');
range = createSemverRange(`>${currentAsString} <=${nextMajor}`);
break;
}
/**
* Accept any version where
* - The overall version is greater than the current one
* - The major version is the same
* - The minor version is either the same or +1
*/
case VersionRelease.Minor: {
const nextMinor = formatSemVer(current.inc('minor'), 'x.x');
range = createSemverRange(`>${currentAsString} <=${nextMinor}`);
break;
}
/**
* Accept any version where
* - The overall version is greater than the current one
* - The major version is the same
* - The minor version is the same
* - The patch version is the same + 1
*/
case VersionRelease.Patch: {
const nextPatch = formatSemVer(current.inc('patch'), 'x.x.x');
range = createSemverRange(`>${currentAsString} <=${nextPatch}`);
break;
}
default:
throw new Error(`Internal error: Invalid version release found: ${version}`);
}
}

const matches = state.available
const matches = available
// Removes invalid versions
.filter(versionFilter)
.filter((semVer) => range.test(semVer))
// Sort from the oldest to the newest
.sort(semver.compare);

const nearest = matches.at(0);
const latest = matches.at(-1);

// TODO: In the following scenario: target=major, current=4.15.4, available=[4.16.0, 5.0.0, 5.2.0, 6.3.0]
// We might want to target 5.2.0 (currently, it'll return 5.0.0)
const target = isSemVer(version) || isLatestVersion(version) ? latest : nearest;
if (!nearest || !latest) {
return undefined;
}

const match = isNextVersion(version) ? nearest : latest;

return target?.raw as SemVer | undefined;
return match?.raw as SemVer;
},
};
};