Skip to content

Commit

Permalink
feat: support pre-release versions in self-update command (#4022)
Browse files Browse the repository at this point in the history
* perf: got rid of unnecessary HTTP requests

This improves performance by avoiding unnecessary target version searches when the version is specified explicitly.
Also, this removes the unnecessary local variable `targetVersion` which has been semantically equal to `desiredVersion`.

* refactor: utility function to find a release by a predicate

The new method `findReleaseVersion` contains machinery to traverse GitHub releases in a pagination mode and get the first one matching a given predicate.

* refactor: move GH helpers to a dedicated namespace

To reduce the semantic load of the `SelfUpdateCommand` class. Those helpers are generic and not tightly related to the semantics of the `self-update` command.

* refactor: inline `targetVersionMatches` method

That method was tightly connected to the caller's logic, and was used by the only caller.

* refactor: helper function to get release artifact details

To isolate the artifacts details collection and return the details as a structured object.
The artifacts details can differ depending on the `desiredVersion` type (edge/pre-release/release).
This change will support pre-prelease versions in `self-update` command.

* refactor: helper function to identify pre-release versions

* feat: support pre-release versions in `self-update` command

* chore: remove unnecessary log line

An empty message on the `error` level gets rendered as `✖ undefined`. That might be not very clear.

* fix: fix version comparing logic

To correctly handle `0.x` -> `0.y` updates as major ones.

---------

Co-authored-by: Orzelius <[email protected]>
  • Loading branch information
vvagaytsev and Orzelius committed Mar 31, 2023
1 parent d276130 commit 91179f1
Show file tree
Hide file tree
Showing 3 changed files with 297 additions and 101 deletions.
251 changes: 153 additions & 98 deletions core/src/commands/self-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,10 @@ import stream from "stream"

const selfUpdateArgs = {
version: new StringParameter({
help: `Specify which version to switch/update to.`,
help: `Specify which version to switch/update to. It can be either a stable release, a pre-release, or an edge release version.`,
}),
}

const versionScopes = ["major", "minor", "patch"] as const
type VersionScope = typeof versionScopes[number]

const selfUpdateOpts = {
"force": new BooleanParameter({
help: `Install the Garden CLI even if the specified or detected latest version is the same as the current version.`,
Expand Down Expand Up @@ -67,6 +64,9 @@ const selfUpdateOpts = {
export type SelfUpdateArgs = typeof selfUpdateArgs
export type SelfUpdateOpts = typeof selfUpdateOpts

const versionScopes = ["major", "minor", "patch"] as const
export type VersionScope = typeof versionScopes[number]

function getVersionScope(opts: ParameterValues<GlobalOptions & SelfUpdateOpts>): VersionScope {
if (opts["major"]) {
return "major"
Expand All @@ -78,16 +78,81 @@ function getVersionScope(opts: ParameterValues<GlobalOptions & SelfUpdateOpts>):
return "patch"
}

export function isEdgeVersion(version: string): boolean {
return version === "edge" || version.startsWith("edge-")
}

export function isPreReleaseVersion(semVersion: semver.SemVer | null): boolean {
return (semVersion?.prerelease.length || 0) > 0
}

interface SelfUpdateResult {
currentVersion: string
latestVersion: string
targetVersion: string
desiredVersion: string
installationDirectory: string
installedBuild?: string
installedVersion?: string
abortReason?: string
}

/**
* Utilities and wrappers on top of GitHub REST API.
*/
namespace GitHubReleaseApi {
/**
* Traverse the Garden releases on GitHub and get the first one matching the given predicate.
*
* @param predicate the predicate to identify the wanted release
*/
export async function findRelease(predicate: (any: any) => boolean) {
const releasesPerPage = 100
let page = 1
let fetchedReleases: any[]
do {
fetchedReleases = await got(
`https://api.github.com/repos/garden-io/garden/releases?page=${page}&per_page=${releasesPerPage}`
).json()
for (const release of fetchedReleases) {
if (predicate(release)) {
return release
}
}
page++
} while (fetchedReleases.length > 0)

return undefined
}

/**
* @return the latest version tag
* @throws {RuntimeError} if the latest version cannot be detected
*/
export async function getLatestVersion(): Promise<string> {
const latestVersionRes: any = await got("https://api.github.com/repos/garden-io/garden/releases/latest").json()
const latestVersion = latestVersionRes.tag_name
if (!latestVersion) {
throw new RuntimeError(`Unable to detect the latest Garden version: ${latestVersionRes}`, {
response: latestVersionRes,
})
}

return latestVersion
}

export async function getLatestVersions(numOfStableVersions: number) {
const res: any = await got("https://api.github.com/repos/garden-io/garden/releases?per_page=100").json()

return [
chalk.cyan("edge"),
...res
.filter((r: any) => !r.prerelease && !r.draft)
.map((r: any) => chalk.cyan(r.name))
.slice(0, numOfStableVersions),
]
}
}

export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
name = "self-update"
help = "Update the Garden CLI."
Expand All @@ -106,7 +171,8 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
garden self-update # update to the latest Garden CLI version
garden self-update edge # switch to the latest edge build (which is created anytime a PR is merged)
garden self-update 0.12.24 # switch to the 0.12.24 version of the CLI
garden self-update 0.12.24 # switch to the 0.12.24 stable version of the CLI
garden self-update 0.13.0-0 # switch to the 0.13.0-0 pre-release version of the CLI
garden self-update --major # install the latest major version (if it exists) greater than the current one
garden self-update --force # re-install even if the same version is detected
garden self-update --install-dir ~/garden # install to ~/garden instead of detecting the directory
Expand All @@ -115,6 +181,7 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
arguments = selfUpdateArgs
options = selfUpdateOpts

_basePreReleasesUrl = "https://github.com/garden-io/garden/releases/download/"
// Overridden during testing
_baseReleasesUrl = "https://download.garden.io/core/"

Expand Down Expand Up @@ -145,18 +212,16 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
installationDirectory = resolve(installationDirectory)

log.info(chalk.white("Checking for target and latest versions..."))

const versionScope: VersionScope = getVersionScope(opts)
const targetVersion = await this.getTargetVersion(currentVersion, versionScope)
const latestVersion = await this.getLatestVersion()
const latestVersion = await GitHubReleaseApi.getLatestVersion()

if (!desiredVersion) {
desiredVersion = targetVersion
const versionScope = getVersionScope(opts)
desiredVersion = await this.findTargetVersion(currentVersion, versionScope)
}

log.info(chalk.white("Installation directory: ") + chalk.cyan(installationDirectory))
log.info(chalk.white("Current Garden version: ") + chalk.cyan(currentVersion))
log.info(chalk.white("Target Garden version to be installed: ") + chalk.cyan(targetVersion))
log.info(chalk.white("Target Garden version to be installed: ") + chalk.cyan(desiredVersion))
log.info(chalk.white("Latest release version: ") + chalk.cyan(latestVersion))

if (!opts.force && !opts["install-dir"] && desiredVersion === currentVersion) {
Expand All @@ -171,7 +236,7 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
currentVersion,
installationDirectory,
latestVersion,
targetVersion,
desiredVersion,
abortReason: "Version already installed",
},
}
Expand All @@ -181,7 +246,6 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
// -> Make sure it's an actual executable, not a script (e.g. from a local dev build)
const expectedExecutableName = process.platform === "win32" ? "garden.exe" : "garden"
if (!opts["install-dir"] && basename(process.execPath) !== expectedExecutableName) {
log.error("")
log.error(
chalk.redBright(
`The executable path ${process.execPath} doesn't indicate this is a normal binary installation for your platform. Perhaps you're running a local development build?`
Expand All @@ -192,7 +256,7 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
currentVersion,
installationDirectory,
latestVersion,
targetVersion,
desiredVersion,
abortReason: "Not running from binary installation",
},
}
Expand All @@ -202,15 +266,10 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {

try {
// Fetch the desired version and extract it to a temp directory
const { build, filename, extension, url } = this.getReleaseArtifactDetails(platform, desiredVersion)
if (!platform) {
platform = getPlatform() === "darwin" ? "macos" : getPlatform()
}
const architecture = "amd64" // getArchitecture()
const extension = platform === "windows" ? "zip" : "tar.gz"
const build = `${platform}-${architecture}`

const filename = `garden-${desiredVersion}-${build}.${extension}`
const url = `${this._baseReleasesUrl}${desiredVersion}/${filename}`

log.info("")
log.info(chalk.white(`Downloading version ${chalk.cyan(desiredVersion)} from ${chalk.underline(url)}...`))
Expand All @@ -228,15 +287,7 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {

// Print the latest available stable versions
try {
const res: any = await got("https://api.github.com/repos/garden-io/garden/releases?per_page=100").json()

const latestVersions = [
chalk.cyan("edge"),
...res
.filter((r: any) => !r.prerelease && !r.draft)
.map((r: any) => chalk.cyan(r.name))
.slice(0, 10),
]
const latestVersions = await GitHubReleaseApi.getLatestVersions(10)

log.info(
chalk.white.bold(`Here are the latest available versions: `) + latestVersions.join(chalk.white(", "))
Expand All @@ -247,7 +298,7 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
result: {
currentVersion,
latestVersion,
targetVersion,
desiredVersion,
installationDirectory,
abortReason: "Version not found",
},
Expand Down Expand Up @@ -309,7 +360,7 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
installedVersion: desiredVersion,
installedBuild: build,
latestVersion,
targetVersion,
desiredVersion,
installationDirectory,
},
}
Expand All @@ -318,6 +369,30 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
}
}

private getReleaseArtifactDetails(platform: string, desiredVersion: string) {
if (!platform) {
platform = getPlatform() === "darwin" ? "macos" : getPlatform()
}
const architecture = "amd64" // getArchitecture()
const extension = platform === "windows" ? "zip" : "tar.gz"
const build = `${platform}-${architecture}`

const desiredSemVer = semver.parse(desiredVersion)

let filename: string
let url: string
if (desiredSemVer && isPreReleaseVersion(desiredSemVer)) {
const desiredVersionWithoutPreRelease = `${desiredSemVer.major}.${desiredSemVer.minor}.${desiredSemVer.patch}`
filename = `garden-${desiredVersionWithoutPreRelease}-${build}.${extension}`
url = `${this._basePreReleasesUrl}${desiredVersion}/${filename}`
} else {
filename = `garden-${desiredVersion}-${build}.${extension}`
url = `${this._baseReleasesUrl}${desiredVersion}/${filename}`
}

return { build, filename, extension, url }
}

/**
* Returns either the latest patch, or minor, or major version greater than {@code currentVersion}
* depending on the {@code versionScope}.
Expand All @@ -330,15 +405,14 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
* @return the matching version tag
* @throws {RuntimeError} if the desired version cannot be detected, or if the current version cannot be recognized as a valid release version
*/
private async getTargetVersion(currentVersion: string, versionScope: VersionScope): Promise<string> {
if (this.isEdgeVersion(currentVersion)) {
return this.getLatestVersion()
private async findTargetVersion(currentVersion: string, versionScope: VersionScope): Promise<string> {
if (isEdgeVersion(currentVersion)) {
return GitHubReleaseApi.getLatestVersion()
}

const currentSemVer = semver.parse(currentVersion)
const isCurrentPrerelease = currentSemVer?.prerelease.length || 0
if (isCurrentPrerelease) {
return this.getLatestVersion()
if (isPreReleaseVersion(currentSemVer)) {
return GitHubReleaseApi.getLatestVersion()
}

// The current version is necessary, it's not possible to proceed without its value
Expand All @@ -349,72 +423,53 @@ export class SelfUpdateCommand extends Command<SelfUpdateArgs, SelfUpdateOpts> {
)
}

const releasesPerPage = 100
let page = 1
let releaseList: any[]
do {
releaseList = await got(
`https://api.github.com/repos/garden-io/garden/releases?page=${page}&per_page=${releasesPerPage}`
).json()
for (const release of releaseList) {
const tagName = release.tag_name
// skip pre-release, draft and edge tags
if (this.isEdgeVersion(tagName) || release.prerelease || release.draft) {
continue
}
const tagSemVer = semver.parse(tagName)
// skip any kind of unexpected tag versions, only stable releases should be processed here
if (!tagSemVer) {
continue
}
if (this.targetVersionMatches(tagSemVer, currentSemVer, versionScope)) {
return tagName
}
}
page++
} while (releaseList.length > 0)
const targetVersionPredicate = this.getTargetVersionPredicate(currentSemVer, versionScope)
const targetRelease = await GitHubReleaseApi.findRelease(targetVersionPredicate)

throw new RuntimeError(
`Unable to detect the latest Garden version greater or equal than ${currentVersion} for the scope: ${versionScope}`,
{}
)
}
if (!targetRelease) {
throw new RuntimeError(
`Unable to find the latest Garden version greater or equal than ${currentVersion} for the scope: ${versionScope}`,
{}
)
}

private isEdgeVersion(version: string) {
return version === "edge" || version.startsWith("edge-")
return targetRelease.tag_name
}

private targetVersionMatches(tagSemVer: semver.SemVer, currentSemVer: semver.SemVer, versionScope: VersionScope) {
switch (versionScope) {
case "major":
return tagSemVer.major >= currentSemVer.major
case "minor":
return tagSemVer.major === currentSemVer.major && tagSemVer.minor >= currentSemVer.minor
case "patch":
return (
tagSemVer.major === currentSemVer.major &&
tagSemVer.minor === currentSemVer.minor &&
tagSemVer.patch >= currentSemVer.patch
)
default: {
const _exhaustiveCheck: never = versionScope
return _exhaustiveCheck
getTargetVersionPredicate(currentSemVer: semver.SemVer, versionScope: VersionScope) {
return function _latestVersionInScope(release: any) {
const tagName = release.tag_name
// skip pre-release, draft and edge tags
if (isEdgeVersion(tagName) || release.prerelease || release.draft) {
return false
}
const tagSemVer = semver.parse(tagName)
// skip any kind of unexpected tag versions, only stable releases should be processed here
if (!tagSemVer) {
return false
}
}
}

/**
* @return the latest version tag
* @throws {RuntimeError} if the latest version cannot be detected
*/
private async getLatestVersion(): Promise<string> {
const latestVersionRes: any = await got("https://api.github.com/repos/garden-io/garden/releases/latest").json()
const latestVersion = latestVersionRes.tag_name
if (!latestVersion) {
throw new RuntimeError(`Unable to detect the latest Garden version: ${latestVersionRes}`, {
response: latestVersionRes,
})
switch (versionScope) {
case "major": {
// TODO Core 1.0 major release: remove this check
if (tagSemVer.major === currentSemVer.major) {
return tagSemVer.minor >= currentSemVer.minor
}
return tagSemVer.major >= currentSemVer.major
}
case "minor":
return tagSemVer.major === currentSemVer.major && tagSemVer.minor >= currentSemVer.minor
case "patch":
return (
tagSemVer.major === currentSemVer.major &&
tagSemVer.minor === currentSemVer.minor &&
tagSemVer.patch >= currentSemVer.patch
)
default: {
const _exhaustiveCheck: never = versionScope
return _exhaustiveCheck
}
}
}
return latestVersionRes.tag_name
}
}
Loading

0 comments on commit 91179f1

Please sign in to comment.