diff --git a/CHANGELOG.md b/CHANGELOG.md index 569454ce4..10f3d7099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Notable changes. +## May 2026 + +### [0.86.1] +- Do not write features supplied via `--additional-features` to the lockfile. (https://github.com/microsoft/vscode-remote-release/issues/11616) + ## April 2026 ### [0.86.0] diff --git a/package.json b/package.json index 8ed5a9b75..c96b33e32 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@devcontainers/cli", "description": "Dev Containers CLI", - "version": "0.86.0", + "version": "0.86.1", "bin": { "devcontainer": "devcontainer.js" }, diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index 78d6c8018..821e50a8e 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -508,7 +508,7 @@ export async function generateFeaturesConfig(params: ContainerFeatureInternalPar await fetchFeatures(params, featuresConfig, dstFolder, ociCacheDir, lockfile); await logFeatureAdvisories(params, featuresConfig); - await writeLockfile(params, config, await generateLockfile(featuresConfig), initLockfile); + await writeLockfile(params, config, await generateLockfile(featuresConfig, config, additionalFeatures), initLockfile); return featuresConfig; } diff --git a/src/spec-configuration/lockfile.ts b/src/spec-configuration/lockfile.ts index 02d0841c3..a1a14755b 100644 --- a/src/spec-configuration/lockfile.ts +++ b/src/spec-configuration/lockfile.ts @@ -13,10 +13,15 @@ export interface Lockfile { features: Record; } -export async function generateLockfile(featuresConfig: FeaturesConfig): Promise { +export async function generateLockfile(featuresConfig: FeaturesConfig, config?: DevContainerConfig, additionalFeatures?: Record>): Promise { + // Features supplied only via `--additional-features` (i.e., not present in `config.features`) + // should not be written to the lockfile. + const configFeatureKeys = new Set(Object.keys(config?.features || {})); + const excludeUserFeatureIds = new Set(Object.keys(additionalFeatures || {}).filter(key => !configFeatureKeys.has(key))); return featuresConfig.featureSets .map(f => [f, f.sourceInformation] as const) .filter((tup): tup is [FeatureSet, OCISourceInformation | DirectTarballSourceInformation] => ['oci', 'direct-tarball'].indexOf(tup[1].type) !== -1) + .filter(([, source]) => !excludeUserFeatureIds.has(source.userFeatureId)) .map(([set, source]) => { const dependsOn = Object.keys(set.features[0].dependsOn || {}); return { diff --git a/src/test/configs/example/.devcontainer.json b/src/test/configs/example/.devcontainer.json index b7e3ab7a0..5f96e6330 100644 --- a/src/test/configs/example/.devcontainer.json +++ b/src/test/configs/example/.devcontainer.json @@ -3,7 +3,7 @@ { "image": "mcr.microsoft.com/devcontainers/base:latest", "features": { - "ghcr.io/devcontainers/features/go:1": { + "ghcr.io/devcontainers/features/github-cli:1": { "version": "latest" } } diff --git a/src/test/container-features/generateLockfile.test.ts b/src/test/container-features/generateLockfile.test.ts new file mode 100644 index 000000000..4737a59a4 --- /dev/null +++ b/src/test/container-features/generateLockfile.test.ts @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { assert } from 'chai'; +import { URI } from 'vscode-uri'; +import { DevContainerConfig } from '../../spec-configuration/configuration'; +import { + DirectTarballSourceInformation, + FeatureSet, + FeaturesConfig, + OCISourceInformation, +} from '../../spec-configuration/containerFeaturesConfiguration'; +import { generateLockfile } from '../../spec-configuration/lockfile'; + +function makeOciFeatureSet(userFeatureId: string, version: string, digest: string): FeatureSet { + const sourceInformation: OCISourceInformation = { + type: 'oci', + userFeatureId, + userFeatureIdWithoutVersion: userFeatureId.split(':')[0], + manifestDigest: digest, + manifest: {} as any, + featureRef: { + registry: 'ghcr.io', + owner: 'devcontainers', + namespace: 'devcontainers/features', + path: `devcontainers/features/${userFeatureId.split('/').pop()!.split(':')[0]}`, + resource: `ghcr.io/${userFeatureId.split(':')[0]}`, + id: userFeatureId.split('/').pop()!.split(':')[0], + version, + tag: version, + }, + }; + return { + sourceInformation, + computedDigest: digest, + features: [ + { + id: sourceInformation.featureRef.id, + version, + value: true, + included: true, + }, + ], + }; +} + +function makeTarballFeatureSet(userFeatureId: string, tarballUri: string, digest: string): FeatureSet { + const sourceInformation: DirectTarballSourceInformation = { + type: 'direct-tarball', + userFeatureId, + tarballUri, + }; + return { + sourceInformation, + computedDigest: digest, + features: [ + { + id: 'mytarball', + version: '1.0.0', + value: true, + included: true, + }, + ], + }; +} + +const mockConfigFilePath = URI.file('/workspace/myProject/.devcontainer/devcontainer.json'); + +describe('generateLockfile', () => { + + it('includes all features when no additionalFeatures are provided', async () => { + const featureSets: FeatureSet[] = [ + makeOciFeatureSet('ghcr.io/devcontainers/features/node:1', '1.0.0', 'sha256:aaa'), + makeOciFeatureSet('ghcr.io/devcontainers/features/git:1', '1.0.0', 'sha256:bbb'), + ]; + const featuresConfig: FeaturesConfig = { featureSets }; + + const lockfile = await generateLockfile(featuresConfig); + + assert.deepEqual(Object.keys(lockfile.features).sort(), [ + 'ghcr.io/devcontainers/features/git:1', + 'ghcr.io/devcontainers/features/node:1', + ]); + }); + + it('excludes features supplied only via additionalFeatures', async () => { + const featureSets: FeatureSet[] = [ + makeOciFeatureSet('ghcr.io/devcontainers/features/node:1', '1.0.0', 'sha256:aaa'), + makeOciFeatureSet('ghcr.io/devcontainers/features/git:1', '1.0.0', 'sha256:bbb'), + ]; + const featuresConfig: FeaturesConfig = { featureSets }; + + const config: DevContainerConfig = { + configFilePath: mockConfigFilePath, + features: { + 'ghcr.io/devcontainers/features/node:1': {}, + }, + }; + const additionalFeatures = { + 'ghcr.io/devcontainers/features/git:1': true, + }; + + const lockfile = await generateLockfile(featuresConfig, config, additionalFeatures); + + assert.deepEqual(Object.keys(lockfile.features), ['ghcr.io/devcontainers/features/node:1']); + }); + + it('keeps features that appear in both config.features and additionalFeatures', async () => { + const featureSets: FeatureSet[] = [ + makeOciFeatureSet('ghcr.io/devcontainers/features/node:1', '1.0.0', 'sha256:aaa'), + ]; + const featuresConfig: FeaturesConfig = { featureSets }; + + const config: DevContainerConfig = { + configFilePath: mockConfigFilePath, + features: { + 'ghcr.io/devcontainers/features/node:1': {}, + }, + }; + const additionalFeatures = { + 'ghcr.io/devcontainers/features/node:1': true, + }; + + const lockfile = await generateLockfile(featuresConfig, config, additionalFeatures); + + assert.deepEqual(Object.keys(lockfile.features), ['ghcr.io/devcontainers/features/node:1']); + }); + + it('excludes additional-only direct-tarball features', async () => { + const featureSets: FeatureSet[] = [ + makeOciFeatureSet('ghcr.io/devcontainers/features/node:1', '1.0.0', 'sha256:aaa'), + makeTarballFeatureSet('https://example.com/devcontainer-feature-mytarball.tgz', 'https://example.com/devcontainer-feature-mytarball.tgz', 'sha256:ccc'), + ]; + const featuresConfig: FeaturesConfig = { featureSets }; + + const config: DevContainerConfig = { + configFilePath: mockConfigFilePath, + features: { + 'ghcr.io/devcontainers/features/node:1': {}, + }, + }; + const additionalFeatures = { + 'https://example.com/devcontainer-feature-mytarball.tgz': true, + }; + + const lockfile = await generateLockfile(featuresConfig, config, additionalFeatures); + + assert.deepEqual(Object.keys(lockfile.features), ['ghcr.io/devcontainers/features/node:1']); + }); + + it('excludes all features when config.features is empty and additionalFeatures provides them all', async () => { + const featureSets: FeatureSet[] = [ + makeOciFeatureSet('ghcr.io/devcontainers/features/node:1', '1.0.0', 'sha256:aaa'), + makeOciFeatureSet('ghcr.io/devcontainers/features/git:1', '1.0.0', 'sha256:bbb'), + ]; + const featuresConfig: FeaturesConfig = { featureSets }; + + const config: DevContainerConfig = { + configFilePath: mockConfigFilePath, + }; + const additionalFeatures = { + 'ghcr.io/devcontainers/features/node:1': true, + 'ghcr.io/devcontainers/features/git:1': true, + }; + + const lockfile = await generateLockfile(featuresConfig, config, additionalFeatures); + + assert.deepEqual(lockfile.features, {}); + }); +});