diff --git a/src/spec-configuration/containerFeaturesOCI.ts b/src/spec-configuration/containerFeaturesOCI.ts index 1b6b4149e..168942030 100644 --- a/src/spec-configuration/containerFeaturesOCI.ts +++ b/src/spec-configuration/containerFeaturesOCI.ts @@ -50,6 +50,11 @@ export interface OCIManifest { annotations?: {}; } +interface OCITagList { + name: string; + tags: string[]; +} + export function getOCIFeatureSet(output: Log, identifier: string, options: boolean | string | Record, manifest: OCIManifest): FeatureSet { const featureRef = getFeatureRef(output, identifier); @@ -155,7 +160,7 @@ export async function fetchOCIFeature(output: Log, env: NodeJS.ProcessEnv, featu throw new Error('FeatureSet is not an OCI featureSet.'); } - const { featureRef } = featureSet.sourceInformation; + const { featureRef } = featureSet.sourceInformation; const blobUrl = `https://${featureSet.sourceInformation.featureRef.registry}/v2/${featureSet.sourceInformation.featureRef.path}/blobs/${featureSet.sourceInformation.manifest?.layers[0].digest}`; output.write(`blob url: ${blobUrl}`, LogLevel.Trace); @@ -292,4 +297,44 @@ export async function fetchRegistryAuthToken(output: Log, registry: string, ociR return undefined; } return token; -} \ No newline at end of file +} + +// Lists published versions of a +// Specification: https://github.com/opencontainers/distribution-spec/blob/v1.0.1/spec.md#content-discovery +export async function getPublishedVersions(featureRef: OCIFeatureRef, output: Log): Promise { + try { + const url = `https://${featureRef.registry}/v2/${featureRef.namespace}/${featureRef.id}/tags/list`; + + let authToken = await fetchRegistryAuthToken(output, featureRef.registry, featureRef.path, process.env, 'pull'); + + if (!authToken) { + output.write(`(!) ERR: Failed to publish feature: ${featureRef.resource}`, LogLevel.Error); + return undefined; + } + + const headers: HEADERS = { + 'user-agent': 'devcontainer', + 'accept': 'application/json', + 'authorization': `Bearer ${authToken}` + }; + + const options = { + type: 'GET', + url: url, + headers: headers + }; + + const response = await request(options); + const publishedVersionsResponse: OCITagList = JSON.parse(response.toString()); + + return publishedVersionsResponse.tags; + } catch (e) { + // Publishing for the first time + if (e?.message.includes('HTTP 404: Not Found')) { + return []; + } + + output.write(`(!) ERR: Failed to publish feature: ${e?.message ?? ''} `, LogLevel.Error); + return undefined; + } +} diff --git a/src/spec-configuration/containerFeaturesOCIPush.ts b/src/spec-configuration/containerFeaturesOCIPush.ts index 1d739daa0..b4d06bed1 100644 --- a/src/spec-configuration/containerFeaturesOCIPush.ts +++ b/src/spec-configuration/containerFeaturesOCIPush.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as crypto from 'crypto'; +import { delay } from '../spec-common/async'; import { headRequest, requestResolveHeaders } from '../spec-utils/httpRequest'; import { Log, LogLevel } from '../spec-utils/log'; import { isLocalFile, readLocalFile } from '../spec-utils/pfs'; @@ -157,7 +158,8 @@ export async function putManifestWithTags(output: Log, manifestStr: string, feat for await (const tag of tags) { const url = `https://${featureRef.registry}/v2/${featureRef.path}/manifests/${tag}`; output.write(`PUT -> '${url}'`, LogLevel.Trace); - const { statusCode, resHeaders } = await requestResolveHeaders({ + + const options = { type: 'PUT', url, headers: { @@ -165,7 +167,20 @@ export async function putManifestWithTags(output: Log, manifestStr: string, feat 'Content-Type': 'application/vnd.oci.image.manifest.v1+json', }, data: Buffer.from(manifestStr), - }); + }; + + let { statusCode, resHeaders } = await requestResolveHeaders(options); + + // Retry logic: when request fails with HTTP 429: too many requests + if (statusCode === 429) { + output.write(`Failed to PUT manifest for tag ${tag} due to too many requests. Retrying...`, LogLevel.Warning); + await delay(2000); + + let response = await requestResolveHeaders(options); + statusCode = response.statusCode; + resHeaders = response.resHeaders; + } + if (statusCode !== 201) { output.write(`Failed to PUT manifest for tag ${tag}`, LogLevel.Error); return false; diff --git a/src/spec-configuration/tsconfig.json b/src/spec-configuration/tsconfig.json index eff319378..f33845140 100644 --- a/src/spec-configuration/tsconfig.json +++ b/src/spec-configuration/tsconfig.json @@ -1,6 +1,9 @@ { "extends": "../../tsconfig.base.json", "references": [ + { + "path": "../spec-common" + }, { "path": "../spec-utils" } diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 44a80e0e5..94eb0921e 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -26,6 +26,7 @@ import { loadNativeModule } from '../spec-common/commonUtils'; import { generateFeaturesConfig, getContainerFeaturesFolder } from '../spec-configuration/containerFeaturesConfiguration'; import { featuresTestOptions, featuresTestHandler } from './featuresCLI/test'; import { featuresPackageHandler, featuresPackageOptions } from './featuresCLI/package'; +import { featuresPublishHandler, featuresPublishOptions } from './featuresCLI/publish'; const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell'; @@ -54,6 +55,7 @@ const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell'; y.command('features', 'Features commands', (y: Argv) => { y.command('test', 'Test features', featuresTestOptions, featuresTestHandler); y.command('package ', 'Package features', featuresPackageOptions, featuresPackageHandler); + y.command('publish ', 'Package and publish features', featuresPublishOptions, featuresPublishHandler); }); y.command(restArgs ? ['exec', '*'] : ['exec [args..]'], 'Execute a command on a running dev container', execOptions, execHandler); y.epilog(`devcontainer@${version} ${packageFolder}`); diff --git a/src/spec-node/featuresCLI/package.ts b/src/spec-node/featuresCLI/package.ts index d67e7a3d1..ec249632a 100644 --- a/src/spec-node/featuresCLI/package.ts +++ b/src/spec-node/featuresCLI/package.ts @@ -2,8 +2,7 @@ import path from 'path'; import { Argv } from 'yargs'; import { CLIHost, getCLIHost } from '../../spec-common/cliHost'; import { loadNativeModule } from '../../spec-common/commonUtils'; -import { Log, LogLevel, mapLogLevel } from '../../spec-utils/log'; -import { isLocalFile, isLocalFolder, mkdirpLocal, rmLocal } from '../../spec-utils/pfs'; +import { Log, mapLogLevel } from '../../spec-utils/log'; import { createLog } from '../devContainers'; import { UnpackArgv } from '../devContainersSpecCLI'; import { getPackageConfig } from '../utils'; @@ -37,7 +36,8 @@ export interface FeaturesPackageCommandInput { outputDir: string; output: Log; disposables: (() => Promise | undefined)[]; - isSingleFeature: boolean; // Packaging a collection of many features. Should autodetect. + isSingleFeature?: boolean; // Packaging a collection of many features. Should autodetect. + forceCleanOutputDir?: boolean; } export function featuresPackageHandler(args: FeaturesPackageArgs) { @@ -67,51 +67,18 @@ async function featuresPackage({ terminalDimensions: undefined, }, pkg, new Date(), disposables); - const targetFolderResolved = cliHost.path.resolve(targetFolder); - if (!(await isLocalFolder(targetFolderResolved))) { - throw new Error(`Target folder '${targetFolderResolved}' does not exist`); - } - - const outputDirResolved = cliHost.path.resolve(outputDir); - if (await isLocalFolder(outputDirResolved)) { - // Output dir exists. Delete it automatically if '-f' is true - if (forceCleanOutputDir) { - await rmLocal(outputDirResolved, { recursive: true, force: true }); - } - else { - output.write(`Output directory '${outputDirResolved}' already exists. Manually delete, or pass '-f' to continue.`, LogLevel.Warning); - process.exit(1); - } - } - - // Detect if we're packaging a collection or a single feature - const isValidFolder = await isLocalFolder(cliHost.path.join(targetFolderResolved)); - const isSingleFeature = await isLocalFile(cliHost.path.join(targetFolderResolved, 'devcontainer-feature.json')); - - if (!isValidFolder) { - throw new Error(`Target folder '${targetFolderResolved}' does not exist`); - } - - if (isSingleFeature) { - output.write('Packaging single feature...', LogLevel.Info); - } else { - output.write('Packaging feature collection...', LogLevel.Info); - } - - // Generate output folder. - await mkdirpLocal(outputDirResolved); const args: FeaturesPackageCommandInput = { cliHost, - targetFolder: targetFolderResolved, - outputDir: outputDirResolved, + targetFolder, + outputDir, output, disposables, - isSingleFeature, + forceCleanOutputDir: forceCleanOutputDir }; - const exitCode = await doFeaturesPackageCommand(args); + const exitCode = !!(await doFeaturesPackageCommand(args)) ? 0 : 1; await dispose(); process.exit(exitCode); -} \ No newline at end of file +} diff --git a/src/spec-node/featuresCLI/packageCommandImpl.ts b/src/spec-node/featuresCLI/packageCommandImpl.ts index 08b9d6977..124118adf 100644 --- a/src/spec-node/featuresCLI/packageCommandImpl.ts +++ b/src/spec-node/featuresCLI/packageCommandImpl.ts @@ -2,7 +2,7 @@ import path from 'path'; import tar from 'tar'; import { Feature } from '../../spec-configuration/containerFeaturesConfiguration'; import { LogLevel } from '../../spec-utils/log'; -import { isLocalFile, readLocalDir, readLocalFile, writeLocalFile } from '../../spec-utils/pfs'; +import { isLocalFile, isLocalFolder, mkdirpLocal, readLocalDir, readLocalFile, rmLocal, writeLocalFile } from '../../spec-utils/pfs'; import { FeaturesPackageCommandInput } from './package'; export interface SourceInformation { source: string; @@ -17,8 +17,59 @@ export interface DevContainerCollectionMetadata { features: Feature[]; } -export async function doFeaturesPackageCommand(args: FeaturesPackageCommandInput): Promise { - const { output, isSingleFeature } = args; +export const OCIFeatureCollectionFileName = 'devcontainer-collection.json'; + +async function prepPackageCommand(args: FeaturesPackageCommandInput): Promise { + const { cliHost, targetFolder, outputDir, forceCleanOutputDir, output, disposables } = args; + + const targetFolderResolved = cliHost.path.resolve(targetFolder); + if (!(await isLocalFolder(targetFolderResolved))) { + throw new Error(`Target folder '${targetFolderResolved}' does not exist`); + } + + const outputDirResolved = cliHost.path.resolve(outputDir); + if (await isLocalFolder(outputDirResolved)) { + // Output dir exists. Delete it automatically if '-f' is true + if (forceCleanOutputDir) { + await rmLocal(outputDirResolved, { recursive: true, force: true }); + } + else { + output.write(`(!) ERR: Output directory '${outputDirResolved}' already exists. Manually delete, or pass '-f' to continue.`, LogLevel.Error); + process.exit(1); + } + } + + // Detect if we're packaging a collection or a single feature + const isValidFolder = await isLocalFolder(cliHost.path.join(targetFolderResolved)); + const isSingleFeature = await isLocalFile(cliHost.path.join(targetFolderResolved, 'devcontainer-feature.json')); + + if (!isValidFolder) { + throw new Error(`Target folder '${targetFolderResolved}' does not exist`); + } + + if (isSingleFeature) { + output.write('Packaging single feature...', LogLevel.Info); + } else { + output.write('Packaging feature collection...', LogLevel.Info); + } + + // Generate output folder. + await mkdirpLocal(outputDirResolved); + + return { + cliHost, + targetFolder: targetFolderResolved, + outputDir: outputDirResolved, + forceCleanOutputDir, + output, + disposables, + isSingleFeature: isSingleFeature + }; +} + +export async function doFeaturesPackageCommand(args: FeaturesPackageCommandInput): Promise { + args = await prepPackageCommand(args); + const { output, isSingleFeature, outputDir } = args; // For each feature, package each feature and write to 'outputDir/{f}.tgz' // Returns an array of feature metadata from each processed feature @@ -33,7 +84,7 @@ export async function doFeaturesPackageCommand(args: FeaturesPackageCommandInput if (!metadataOutput) { output.write('Failed to package features', LogLevel.Error); - return 1; + return undefined; } const collection: DevContainerCollectionMetadata = { @@ -44,16 +95,16 @@ export async function doFeaturesPackageCommand(args: FeaturesPackageCommandInput }; // Write the metadata to a file - const metadataOutputPath = path.join(args.outputDir, 'devcontainer-collection.json'); + const metadataOutputPath = path.join(outputDir, OCIFeatureCollectionFileName); await writeLocalFile(metadataOutputPath, JSON.stringify(collection, null, 4)); - return 0; + return collection; } async function tarDirectory(featureFolder: string, archiveName: string, outputDir: string) { return new Promise((resolve) => resolve(tar.create({ file: path.join(outputDir, archiveName), cwd: featureFolder }, ['.']))); } -const getArchiveName = (f: string) => `devcontainer-feature-${f}.tgz`; +export const getFeatureArchiveName = (f: string) => `devcontainer-feature-${f}.tgz`; export async function packageSingleFeature(args: FeaturesPackageCommandInput): Promise { const { output, targetFolder, outputDir } = args; @@ -65,7 +116,7 @@ export async function packageSingleFeature(args: FeaturesPackageCommandInput): P output.write(`Feature is missing an id or version in its devcontainer-feature.json`, LogLevel.Error); return; } - const archiveName = getArchiveName(featureMetadata.id); + const archiveName = getFeatureArchiveName(featureMetadata.id); await tarDirectory(targetFolder, archiveName, outputDir); output.write(`Packaged feature '${featureMetadata.id}'`, LogLevel.Info); @@ -85,7 +136,7 @@ export async function packageCollection(args: FeaturesPackageCommandInput): Prom output.write(`Processing feature: ${f}...`, LogLevel.Info); if (!f.startsWith('.')) { const featureFolder = path.join(srcFolder, f); - const archiveName = getArchiveName(f); + const archiveName = getFeatureArchiveName(f); // Validate minimal feature folder structure const featureJsonPath = path.join(featureFolder, 'devcontainer-feature.json'); @@ -116,4 +167,4 @@ export async function packageCollection(args: FeaturesPackageCommandInput): Prom output.write(`Packaged ${metadatas.length} features!`, LogLevel.Info); return metadatas; -} \ No newline at end of file +} diff --git a/src/spec-node/featuresCLI/publish.ts b/src/spec-node/featuresCLI/publish.ts new file mode 100644 index 000000000..2690aec65 --- /dev/null +++ b/src/spec-node/featuresCLI/publish.ts @@ -0,0 +1,108 @@ +import path from 'path'; +import * as os from 'os'; +import { Argv } from 'yargs'; +import { LogLevel, mapLogLevel } from '../../spec-utils/log'; +import { rmLocal } from '../../spec-utils/pfs'; +import { getPackageConfig } from '../../spec-utils/product'; +import { createLog } from '../devContainers'; +import { UnpackArgv } from '../devContainersSpecCLI'; +import { FeaturesPackageCommandInput } from './package'; +import { OCIFeatureCollectionFileName, doFeaturesPackageCommand } from './packageCommandImpl'; +import { doFeaturesPublishCommand, doFeaturesPublishMetadata } from './publishCommandImpl'; +import { getFeatureRef, OCIFeatureCollectionRef } from '../../spec-configuration/containerFeaturesOCI'; +import { getCLIHost } from '../../spec-common/cliHost'; +import { loadNativeModule } from '../../spec-common/commonUtils'; + +const targetPositionalDescription = ` +Package and publish features at provided [target] (default is cwd), where [target] is either: + 1. A path to the src folder of the collection with [1..n] features. + 2. A path to a single feature that contains a devcontainer-feature.json. +`; + +export function featuresPublishOptions(y: Argv) { + return y + .options({ + 'registry': { type: 'string', alias: 'r', default: 'ghcr.io', description: 'Name of the OCI registry.' }, + 'namespace': { type: 'string', alias: 'n', require: true, description: 'Unique indentifier for the collection of features. Example: /' }, + 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level.' } + }) + .positional('target', { type: 'string', default: '.', description: targetPositionalDescription }) + .check(_argv => { + return true; + }); +} + +export type FeaturesPublishArgs = UnpackArgv>; + +export function featuresPublishHandler(args: FeaturesPublishArgs) { + (async () => await featuresPublish(args))().catch(console.error); +} + +async function featuresPublish({ + 'target': targetFolder, + 'log-level': inputLogLevel, + 'registry': registry, + 'namespace': namespace +}: FeaturesPublishArgs) { + const disposables: (() => Promise | undefined)[] = []; + const dispose = async () => { + await Promise.all(disposables.map(d => d())); + }; + + const extensionPath = path.join(__dirname, '..', '..', '..'); + const pkg = await getPackageConfig(extensionPath); + + const cwd = process.cwd(); + const cliHost = await getCLIHost(cwd, loadNativeModule); + const output = createLog({ + logLevel: mapLogLevel(inputLogLevel), + logFormat: 'text', + log: (str) => process.stdout.write(str), + terminalDimensions: undefined, + }, pkg, new Date(), disposables); + + // Package features + const outputDir = path.join(os.tmpdir(), '/features-output'); + + const packageArgs: FeaturesPackageCommandInput = { + cliHost, + targetFolder, + outputDir, + output, + disposables, + forceCleanOutputDir: true, + }; + + const metadata = await doFeaturesPackageCommand(packageArgs); + + if (!metadata) { + output.write(`(!) ERR: Failed to fetch ${OCIFeatureCollectionFileName}`, LogLevel.Error); + process.exit(1); + } + + for (const f of metadata.features) { + output.write(`Processing feature: ${f.id}...`, LogLevel.Info); + + if (!f.version) { + output.write(`(!) WARNING: Version does not exist, skipping ${f.id}...`, LogLevel.Warning); + continue; + } + + const resource = `${registry}/${namespace}/${f.id}`; + const featureRef = getFeatureRef(output, resource); + await doFeaturesPublishCommand(f.version, featureRef, outputDir, output); + } + + const featureCollectionRef: OCIFeatureCollectionRef = { + registry: registry, + path: namespace, + version: 'latest' + }; + + await doFeaturesPublishMetadata(featureCollectionRef, outputDir, output); + + // Cleanup + await rmLocal(outputDir, { recursive: true, force: true }); + await dispose(); + process.exit(); +} diff --git a/src/spec-node/featuresCLI/publishCommandImpl.ts b/src/spec-node/featuresCLI/publishCommandImpl.ts new file mode 100644 index 000000000..d5205427a --- /dev/null +++ b/src/spec-node/featuresCLI/publishCommandImpl.ts @@ -0,0 +1,74 @@ +import path from 'path'; +import * as semver from 'semver'; +import { getPublishedVersions, OCIFeatureCollectionRef, OCIFeatureRef } from '../../spec-configuration/containerFeaturesOCI'; +import { pushFeatureCollectionMetadata, pushOCIFeature } from '../../spec-configuration/containerFeaturesOCIPush'; +import { Log, LogLevel } from '../../spec-utils/log'; +import { getFeatureArchiveName, OCIFeatureCollectionFileName } from './packageCommandImpl'; + +let semanticVersions: string[] = []; + +function updateSemanticVersionsList(publishedVersions: string[], version: string, range: string, publishVersion: string) { + // Reference: https://github.com/npm/node-semver#ranges-1 + const publishedMaxVersion = semver.maxSatisfying(publishedVersions, range); + if (publishedMaxVersion === null || semver.compare(version, publishedMaxVersion) === 1) { + semanticVersions.push(publishVersion); + } + return; +} + +export function getSermanticVersions(version: string, publishedVersions: string[], output: Log) { + if (publishedVersions.includes(version)) { + output.write(`(!) WARNING: Version ${version} already exists, skipping ${version}...`, LogLevel.Warning); + return undefined; + } + + const parsedVersion = semver.parse(version); + if (!parsedVersion) { + output.write(`(!) ERR: Version ${version} is not a valid semantic version, skipping ${version}...`, LogLevel.Error); + process.exit(1); + } + + semanticVersions = []; + + // Adds semantic versions depending upon the existings (published) versions + // eg. 1.2.3 --> [1, 1.2, 1.2.3, latest] + updateSemanticVersionsList(publishedVersions, version, `${parsedVersion.major}.x.x`, `${parsedVersion.major}`); + updateSemanticVersionsList(publishedVersions, version, `${parsedVersion.major}.${parsedVersion.minor}.x`, `${parsedVersion.major}.${parsedVersion.minor}`); + semanticVersions.push(version); + updateSemanticVersionsList(publishedVersions, version, `x.x.x`, 'latest'); + + return semanticVersions; +} + +export async function doFeaturesPublishCommand(version: string, featureRef: OCIFeatureRef, outputDir: string, output: Log) { + output.write(`Fetching published versions...`, LogLevel.Info); + const publishedVersions = await getPublishedVersions(featureRef, output); + + if (!publishedVersions) { + process.exit(1); + } + + const semanticVersions: string[] | undefined = getSermanticVersions(version, publishedVersions, output); + + if (!!semanticVersions) { + output.write(`Publishing versions: ${semanticVersions.toString()}...`, LogLevel.Info); + const pathToTgz = path.join(outputDir, getFeatureArchiveName(featureRef.id)); + if (! await pushOCIFeature(output, featureRef, pathToTgz, semanticVersions)) { + output.write(`(!) ERR: Failed to publish feature: ${featureRef.resource}`, LogLevel.Error); + process.exit(1); + } + output.write(`Published feature: ${featureRef.id}...`, LogLevel.Info); + } +} + +export async function doFeaturesPublishMetadata(featureCollectionRef: OCIFeatureCollectionRef, outputDir: string, output: Log) { + // Publishing Feature Collection Metadata + output.write('Publishing collection metadata...', LogLevel.Info); + + const pathToFeatureCollectionFile = path.join(outputDir, OCIFeatureCollectionFileName); + if (! await pushFeatureCollectionMetadata(output, featureCollectionRef, pathToFeatureCollectionFile)) { + output.write(`(!) ERR: Failed to publish collection metadata: ${OCIFeatureCollectionFileName}`, LogLevel.Error); + process.exit(1); + } + output.write('Published collection metadata...', LogLevel.Info); +} diff --git a/src/test/container-features/featuresCLICommands.test.ts b/src/test/container-features/featuresCLICommands.test.ts index 020007b25..45515efd8 100644 --- a/src/test/container-features/featuresCLICommands.test.ts +++ b/src/test/container-features/featuresCLICommands.test.ts @@ -1,5 +1,7 @@ import { assert } from 'chai'; import path from 'path'; +import { getFeatureRef, getPublishedVersions } from '../../spec-configuration/containerFeaturesOCI'; +import { getSermanticVersions } from '../../spec-node/featuresCLI/publishCommandImpl'; import { createPlainLog, LogLevel, makeLog } from '../../spec-utils/log'; import { isLocalFile, readLocalFile } from '../../spec-utils/pfs'; import { shellExec } from '../testUtils'; @@ -84,4 +86,77 @@ describe('CLI features subcommands', async function () { assert.strictEqual(json.features.length, 1); assert.isTrue(collectionFileExists); }); -}); \ No newline at end of file +}); + +describe('test function getSermanticVersions', () => { + it('should generate correct semantic versions for first publishing', async () => { + let version = '1.0.0'; + let publishedVersions: string[] = []; + let expectedSemVer = ['1', '1.0', '1.0.0', 'latest']; + + let semanticVersions = getSermanticVersions(version, publishedVersions, output); + assert.equal(semanticVersions?.toString(), expectedSemVer.toString()); + }); + + it('should generate correct semantic versions for publishing new patch version', async () => { + let version = '1.0.1'; + let publishedVersions = ['1', '1.0', '1.0.0', 'latest']; + let expectedSemVer = ['1', '1.0', '1.0.1', 'latest']; + + let semanticVersions = getSermanticVersions(version, publishedVersions, output); + assert.equal(semanticVersions?.toString(), expectedSemVer.toString()); + }); + + it('should generate correct semantic versions for publishing new minor version', async () => { + let version = '1.1.0'; + let publishedVersions = ['1', '1.0', '1.0.0', '1.0.1', 'latest']; + let expectedSemVer = ['1', '1.1', '1.1.0', 'latest']; + + let semanticVersions = getSermanticVersions(version, publishedVersions, output); + assert.equal(semanticVersions?.toString(), expectedSemVer.toString()); + }); + + it('should generate correct semantic versions for publishing new major version', async () => { + let version = '2.0.0'; + let publishedVersions = ['1', '1.0', '1.0.0', 'latest']; + let expectedSemVer = ['2', '2.0', '2.0.0', 'latest']; + + let semanticVersions = getSermanticVersions(version, publishedVersions, output); + assert.equal(semanticVersions?.toString(), expectedSemVer.toString()); + }); + + it('should generate correct semantic versions for publishing hotfix patch version', async () => { + let version = '1.0.2'; + let publishedVersions = ['1', '1.0', '1.0.0', '1.0.1', '1.1', '1.1.0', '2', '2.0', '2.0.0', 'latest']; + let expectedSemVer = ['1.0', '1.0.2']; + + let semanticVersions = getSermanticVersions(version, publishedVersions, output); + assert.equal(semanticVersions?.toString(), expectedSemVer.toString()); + }); + + it('should generate correct semantic versions for publishing hotfix minor version', async () => { + let version = '1.0.1'; + let publishedVersions = ['1', '1.0', '1.0.0', '2', '2.0', '2.0.0', 'latest']; + let expectedSemVer = ['1', '1.0', '1.0.1']; + + let semanticVersions = getSermanticVersions(version, publishedVersions, output); + assert.equal(semanticVersions?.toString(), expectedSemVer.toString()); + }); + + it('should return undefined for already published version', async () => { + let version = '1.0.1'; + let publishedVersions = ['1', '1.0', '1.0.0', '1.0.1', '2', '2.0', '2.0.0', 'latest']; + + let semanticVersions = getSermanticVersions(version, publishedVersions, output); + assert.isUndefined(semanticVersions); + }); +}); + +describe('test function getPublishedVersions', async () => { + it('should list published versions', async () => { + const resource = 'ghcr.io/devcontainers/features/node'; + const featureRef = getFeatureRef(output, resource); + const versionsList = await getPublishedVersions(featureRef, output) ?? []; + assert.includeMembers(versionsList, ['1', '1.0', '1.0.0', 'latest']); + }); +});