Skip to content

Commit 6293ce5

Browse files
authored
Do not write features supplied via --additional-features to the lockfile (microsoft/vscode-remote-release#11616)
1 parent 131882d commit 6293ce5

6 files changed

Lines changed: 186 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
Notable changes.
44

5+
## May 2026
6+
7+
### [0.86.1]
8+
- Do not write features supplied via `--additional-features` to the lockfile. (https://github.com/microsoft/vscode-remote-release/issues/11616)
9+
510
## April 2026
611

712
### [0.86.0]

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@devcontainers/cli",
33
"description": "Dev Containers CLI",
4-
"version": "0.86.0",
4+
"version": "0.86.1",
55
"bin": {
66
"devcontainer": "devcontainer.js"
77
},

src/spec-configuration/containerFeaturesConfiguration.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,7 @@ export async function generateFeaturesConfig(params: ContainerFeatureInternalPar
508508
await fetchFeatures(params, featuresConfig, dstFolder, ociCacheDir, lockfile);
509509

510510
await logFeatureAdvisories(params, featuresConfig);
511-
await writeLockfile(params, config, await generateLockfile(featuresConfig), initLockfile);
511+
await writeLockfile(params, config, await generateLockfile(featuresConfig, config, additionalFeatures), initLockfile);
512512
return featuresConfig;
513513
}
514514

src/spec-configuration/lockfile.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,15 @@ export interface Lockfile {
1313
features: Record<string, { version: string; resolved: string; integrity: string }>;
1414
}
1515

16-
export async function generateLockfile(featuresConfig: FeaturesConfig): Promise<Lockfile> {
16+
export async function generateLockfile(featuresConfig: FeaturesConfig, config?: DevContainerConfig, additionalFeatures?: Record<string, string | boolean | Record<string, string | boolean>>): Promise<Lockfile> {
17+
// Features supplied only via `--additional-features` (i.e., not present in `config.features`)
18+
// should not be written to the lockfile.
19+
const configFeatureKeys = new Set(Object.keys(config?.features || {}));
20+
const excludeUserFeatureIds = new Set(Object.keys(additionalFeatures || {}).filter(key => !configFeatureKeys.has(key)));
1721
return featuresConfig.featureSets
1822
.map(f => [f, f.sourceInformation] as const)
1923
.filter((tup): tup is [FeatureSet, OCISourceInformation | DirectTarballSourceInformation] => ['oci', 'direct-tarball'].indexOf(tup[1].type) !== -1)
24+
.filter(([, source]) => !excludeUserFeatureIds.has(source.userFeatureId))
2025
.map(([set, source]) => {
2126
const dependsOn = Object.keys(set.features[0].dependsOn || {});
2227
return {

src/test/configs/example/.devcontainer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
{
44
"image": "mcr.microsoft.com/devcontainers/base:latest",
55
"features": {
6-
"ghcr.io/devcontainers/features/go:1": {
6+
"ghcr.io/devcontainers/features/github-cli:1": {
77
"version": "latest"
88
}
99
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { assert } from 'chai';
7+
import { URI } from 'vscode-uri';
8+
import { DevContainerConfig } from '../../spec-configuration/configuration';
9+
import {
10+
DirectTarballSourceInformation,
11+
FeatureSet,
12+
FeaturesConfig,
13+
OCISourceInformation,
14+
} from '../../spec-configuration/containerFeaturesConfiguration';
15+
import { generateLockfile } from '../../spec-configuration/lockfile';
16+
17+
function makeOciFeatureSet(userFeatureId: string, version: string, digest: string): FeatureSet {
18+
const sourceInformation: OCISourceInformation = {
19+
type: 'oci',
20+
userFeatureId,
21+
userFeatureIdWithoutVersion: userFeatureId.split(':')[0],
22+
manifestDigest: digest,
23+
manifest: {} as any,
24+
featureRef: {
25+
registry: 'ghcr.io',
26+
owner: 'devcontainers',
27+
namespace: 'devcontainers/features',
28+
path: `devcontainers/features/${userFeatureId.split('/').pop()!.split(':')[0]}`,
29+
resource: `ghcr.io/${userFeatureId.split(':')[0]}`,
30+
id: userFeatureId.split('/').pop()!.split(':')[0],
31+
version,
32+
tag: version,
33+
},
34+
};
35+
return {
36+
sourceInformation,
37+
computedDigest: digest,
38+
features: [
39+
{
40+
id: sourceInformation.featureRef.id,
41+
version,
42+
value: true,
43+
included: true,
44+
},
45+
],
46+
};
47+
}
48+
49+
function makeTarballFeatureSet(userFeatureId: string, tarballUri: string, digest: string): FeatureSet {
50+
const sourceInformation: DirectTarballSourceInformation = {
51+
type: 'direct-tarball',
52+
userFeatureId,
53+
tarballUri,
54+
};
55+
return {
56+
sourceInformation,
57+
computedDigest: digest,
58+
features: [
59+
{
60+
id: 'mytarball',
61+
version: '1.0.0',
62+
value: true,
63+
included: true,
64+
},
65+
],
66+
};
67+
}
68+
69+
const mockConfigFilePath = URI.file('/workspace/myProject/.devcontainer/devcontainer.json');
70+
71+
describe('generateLockfile', () => {
72+
73+
it('includes all features when no additionalFeatures are provided', async () => {
74+
const featureSets: FeatureSet[] = [
75+
makeOciFeatureSet('ghcr.io/devcontainers/features/node:1', '1.0.0', 'sha256:aaa'),
76+
makeOciFeatureSet('ghcr.io/devcontainers/features/git:1', '1.0.0', 'sha256:bbb'),
77+
];
78+
const featuresConfig: FeaturesConfig = { featureSets };
79+
80+
const lockfile = await generateLockfile(featuresConfig);
81+
82+
assert.deepEqual(Object.keys(lockfile.features).sort(), [
83+
'ghcr.io/devcontainers/features/git:1',
84+
'ghcr.io/devcontainers/features/node:1',
85+
]);
86+
});
87+
88+
it('excludes features supplied only via additionalFeatures', async () => {
89+
const featureSets: FeatureSet[] = [
90+
makeOciFeatureSet('ghcr.io/devcontainers/features/node:1', '1.0.0', 'sha256:aaa'),
91+
makeOciFeatureSet('ghcr.io/devcontainers/features/git:1', '1.0.0', 'sha256:bbb'),
92+
];
93+
const featuresConfig: FeaturesConfig = { featureSets };
94+
95+
const config: DevContainerConfig = {
96+
configFilePath: mockConfigFilePath,
97+
features: {
98+
'ghcr.io/devcontainers/features/node:1': {},
99+
},
100+
};
101+
const additionalFeatures = {
102+
'ghcr.io/devcontainers/features/git:1': true,
103+
};
104+
105+
const lockfile = await generateLockfile(featuresConfig, config, additionalFeatures);
106+
107+
assert.deepEqual(Object.keys(lockfile.features), ['ghcr.io/devcontainers/features/node:1']);
108+
});
109+
110+
it('keeps features that appear in both config.features and additionalFeatures', async () => {
111+
const featureSets: FeatureSet[] = [
112+
makeOciFeatureSet('ghcr.io/devcontainers/features/node:1', '1.0.0', 'sha256:aaa'),
113+
];
114+
const featuresConfig: FeaturesConfig = { featureSets };
115+
116+
const config: DevContainerConfig = {
117+
configFilePath: mockConfigFilePath,
118+
features: {
119+
'ghcr.io/devcontainers/features/node:1': {},
120+
},
121+
};
122+
const additionalFeatures = {
123+
'ghcr.io/devcontainers/features/node:1': true,
124+
};
125+
126+
const lockfile = await generateLockfile(featuresConfig, config, additionalFeatures);
127+
128+
assert.deepEqual(Object.keys(lockfile.features), ['ghcr.io/devcontainers/features/node:1']);
129+
});
130+
131+
it('excludes additional-only direct-tarball features', async () => {
132+
const featureSets: FeatureSet[] = [
133+
makeOciFeatureSet('ghcr.io/devcontainers/features/node:1', '1.0.0', 'sha256:aaa'),
134+
makeTarballFeatureSet('https://example.com/devcontainer-feature-mytarball.tgz', 'https://example.com/devcontainer-feature-mytarball.tgz', 'sha256:ccc'),
135+
];
136+
const featuresConfig: FeaturesConfig = { featureSets };
137+
138+
const config: DevContainerConfig = {
139+
configFilePath: mockConfigFilePath,
140+
features: {
141+
'ghcr.io/devcontainers/features/node:1': {},
142+
},
143+
};
144+
const additionalFeatures = {
145+
'https://example.com/devcontainer-feature-mytarball.tgz': true,
146+
};
147+
148+
const lockfile = await generateLockfile(featuresConfig, config, additionalFeatures);
149+
150+
assert.deepEqual(Object.keys(lockfile.features), ['ghcr.io/devcontainers/features/node:1']);
151+
});
152+
153+
it('excludes all features when config.features is empty and additionalFeatures provides them all', async () => {
154+
const featureSets: FeatureSet[] = [
155+
makeOciFeatureSet('ghcr.io/devcontainers/features/node:1', '1.0.0', 'sha256:aaa'),
156+
makeOciFeatureSet('ghcr.io/devcontainers/features/git:1', '1.0.0', 'sha256:bbb'),
157+
];
158+
const featuresConfig: FeaturesConfig = { featureSets };
159+
160+
const config: DevContainerConfig = {
161+
configFilePath: mockConfigFilePath,
162+
};
163+
const additionalFeatures = {
164+
'ghcr.io/devcontainers/features/node:1': true,
165+
'ghcr.io/devcontainers/features/git:1': true,
166+
};
167+
168+
const lockfile = await generateLockfile(featuresConfig, config, additionalFeatures);
169+
170+
assert.deepEqual(lockfile.features, {});
171+
});
172+
});

0 commit comments

Comments
 (0)