Skip to content
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
"integrity": "sha256:ce078b7bf7d9ef3bcb9813b32103795d8d72172446890b64772cbe1dec6baafd"
}
}
}
}
3 changes: 2 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"GitHub.vscode-pull-request-github"
"GitHub.vscode-pull-request-github",
"hbenl.vscode-mocha-test-adapter"
]
},
"codespaces": {
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ Notable changes.

## April 2026

### [0.87.0]
- Graduate lockfile from experimental to stable: lockfiles are now generated by default on `build` and `up`. (https://github.com/devcontainers/cli/issues/1195)
- New `--no-lockfile` flag to opt out of lockfile generation.
- New `--frozen-lockfile` flag to ensure the lockfile exists and remains unchanged.
- `--experimental-lockfile` and `--experimental-frozen-lockfile` are deprecated (still accepted with a warning).

### [0.86.0]
- Bump basic-ftp from 5.2.0 to 5.2.2. (https://github.com/devcontainers/cli/pull/1201)
- Always write devcontainer.metadata label as JSON array. (https://github.com/devcontainers/cli/pull/1199)
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,15 @@ This CLI is in active development. Current status:
- [x] `devcontainer run-user-commands` - Runs lifecycle commands like `postCreateCommand`
- [x] `devcontainer read-configuration` - Outputs current configuration for workspace
- [x] `devcontainer exec` - Executes a command in a container with `userEnvProbe`, `remoteUser`, `remoteEnv`, and other properties applied
- [x] `devcontainer outdated` - Show outdated lockfile features
- [x] `devcontainer upgrade` - Upgrade lockfile features
- [x] `devcontainer features <...>` - Tools to assist in authoring and testing [Dev Container Features](https://containers.dev/implementors/features/)
- [x] `devcontainer templates <...>` - Tools to assist in authoring and testing [Dev Container Templates](https://containers.dev/implementors/templates/)
- [ ] `devcontainer stop` - Stops containers
- [ ] `devcontainer down` - Stops and deletes containers

Lockfiles (`.devcontainer-lock.json`) are generated by default when running `build` or `up` to pin feature versions for reproducible builds. Use `--no-lockfile` to opt out, or `--frozen-lockfile` to enforce an existing lockfile.

## Try it out

We'd love for you to try out the dev container CLI and let us know what you think. You can quickly try it out in just a few simple steps, either by using the install script, installing its npm package, or building the CLI repo from sources (see "[Build from sources](#build-from-sources)").
Expand Down
8 changes: 8 additions & 0 deletions docs/contributing-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ node devcontainer.js run-user-commands --workspace-folder <path>

Tests use [Mocha](https://mochajs.org/) and [Chai](https://www.chaijs.com/) and require Docker because they create and tear down real containers.

Before running tests, package the CLI into a tarball:

```sh
npm run package
```

Tests install the CLI from the generated `devcontainers-cli-<version>.tgz` and shell out to it as a subprocess. You must re-run `npm run package` after any code change so that the tarball reflects your latest changes. Running `npm run compile` alone is **not** sufficient — it builds the JavaScript output but does not create the tarball that the tests depend on.

```sh
npm test # all tests
npm run test-container-features # Features tests only
Expand Down
8 changes: 6 additions & 2 deletions src/spec-configuration/containerFeaturesConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ export interface ContainerFeatureInternalParams {
platform: NodeJS.Platform;
experimentalLockfile?: boolean;
Comment thread
chrmarti marked this conversation as resolved.
Outdated
experimentalFrozenLockfile?: boolean;
noLockfile?: boolean;
frozenLockfile?: boolean;
}

// TODO: Move to node layer.
Expand Down Expand Up @@ -485,7 +487,7 @@ export async function generateFeaturesConfig(params: ContainerFeatureInternalPar

const ociCacheDir = await prepareOCICache(dstFolder);

const { lockfile, initLockfile } = await readLockfile(config);
const { lockfile } = params.noLockfile ? { lockfile: undefined } : await readLockfile(config);

const processFeature = async (_userFeature: DevContainerFeature) => {
return await processFeatureIdentifier(params, configPath, workspaceRoot, _userFeature, lockfile);
Expand All @@ -508,7 +510,9 @@ 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);
if (!params.noLockfile) {
await writeLockfile(params, config, await generateLockfile(featuresConfig));
}
return featuresConfig;
}

Expand Down
14 changes: 7 additions & 7 deletions src/spec-configuration/lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ export async function generateLockfile(featuresConfig: FeaturesConfig): Promise<
});
}

export async function writeLockfile(params: ContainerFeatureInternalParams, config: DevContainerConfig, lockfile: Lockfile, forceInitLockfile?: boolean): Promise<string | undefined> {
export async function writeLockfile(params: ContainerFeatureInternalParams, config: DevContainerConfig, lockfile: Lockfile): Promise<string | undefined> {
if (params.noLockfile) {
return;
}

const lockfilePath = getLockfilePath(config);
const oldLockfileContent = await readLocalFile(lockfilePath)
.catch(err => {
Expand All @@ -49,14 +53,10 @@ export async function writeLockfile(params: ContainerFeatureInternalParams, conf
}
});

if (!forceInitLockfile && !oldLockfileContent && !params.experimentalLockfile && !params.experimentalFrozenLockfile) {
return;
}

// Trailing newline per POSIX convention
const newLockfileContentString = JSON.stringify(lockfile, null, 2) + '\n';
const newLockfileContent = Buffer.from(newLockfileContentString);
if (params.experimentalFrozenLockfile && !oldLockfileContent) {
if ((params.frozenLockfile || params.experimentalFrozenLockfile) && !oldLockfileContent) {
Comment thread
chrmarti marked this conversation as resolved.
Outdated
throw new Error('Lockfile does not exist.');
}
// Normalize the existing lockfile through JSON.parse -> JSON.stringify to produce
Expand All @@ -71,7 +71,7 @@ export async function writeLockfile(params: ContainerFeatureInternalParams, conf
}
}
if (!oldLockfileNormalized || oldLockfileNormalized !== newLockfileContentString) {
if (params.experimentalFrozenLockfile) {
if (params.frozenLockfile || params.experimentalFrozenLockfile) {
throw new Error('Lockfile does not match.');
}
await writeLocalFile(lockfilePath, newLockfileContent);
Expand Down
4 changes: 2 additions & 2 deletions src/spec-node/containerFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,8 @@ export async function getExtendImageBuildInfo(params: DockerResolverParameters,
const platform = params.common.cliHost.platform;

const cacheFolder = await getCacheFolder(params.common.cliHost);
const { experimentalLockfile, experimentalFrozenLockfile } = params;
const featuresConfig = await generateFeaturesConfig({ ...params.common, platform, cacheFolder, experimentalLockfile, experimentalFrozenLockfile }, dstFolder, config.config, additionalFeatures);
const { experimentalLockfile, experimentalFrozenLockfile, noLockfile, frozenLockfile } = params;
const featuresConfig = await generateFeaturesConfig({ ...params.common, platform, cacheFolder, experimentalLockfile, experimentalFrozenLockfile, noLockfile, frozenLockfile }, dstFolder, config.config, additionalFeatures);
if (!featuresConfig) {
if (canAddLabelsToContainer && !imageBuildInfo.dockerfile) {
return {
Expand Down
6 changes: 5 additions & 1 deletion src/spec-node/devContainers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export interface ProvisionOptions {
};
experimentalLockfile?: boolean;
experimentalFrozenLockfile?: boolean;
noLockfile?: boolean;
frozenLockfile?: boolean;
secretsP?: Promise<Record<string, string>>;
omitSyntaxDirective?: boolean;
includeConfig?: boolean;
Expand Down Expand Up @@ -103,7 +105,7 @@ export async function launch(options: ProvisionOptions, providedIdLabels: string
}

export async function createDockerParams(options: ProvisionOptions, disposables: (() => Promise<unknown> | undefined)[]): Promise<DockerResolverParameters> {
const { persistedFolder, additionalMounts, updateRemoteUserUIDDefault, containerDataFolder, containerSystemDataFolder, workspaceMountConsistency, gpuAvailability, mountWorkspaceGitRoot, mountGitWorktreeCommonDir, remoteEnv, experimentalLockfile, experimentalFrozenLockfile, omitLoggerHeader, secretsP } = options;
const { persistedFolder, additionalMounts, updateRemoteUserUIDDefault, containerDataFolder, containerSystemDataFolder, workspaceMountConsistency, gpuAvailability, mountWorkspaceGitRoot, mountGitWorktreeCommonDir, remoteEnv, experimentalLockfile, experimentalFrozenLockfile, noLockfile, frozenLockfile, omitLoggerHeader, secretsP } = options;
let parsedAuthority: DevContainerAuthority | undefined;
if (options.workspaceFolder) {
parsedAuthority = { hostPath: options.workspaceFolder } as DevContainerAuthority;
Expand Down Expand Up @@ -248,6 +250,8 @@ export async function createDockerParams(options: ProvisionOptions, disposables:
isTTY: process.stdout.isTTY || options.logFormat === 'json',
experimentalLockfile,
experimentalFrozenLockfile,
noLockfile,
frozenLockfile,
buildxPlatform: common.buildxPlatform,
buildxPush: common.buildxPush,
additionalLabels: options.additionalLabels,
Expand Down
51 changes: 50 additions & 1 deletion src/spec-node/devContainersSpecCLI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ function provisionOptions(y: Argv) {
'secrets-file': { type: 'string', description: 'Path to a json file containing secret environment variables as key-value pairs.' },
'experimental-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Write lockfile' },
'experimental-frozen-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Ensure lockfile remains unchanged' },
'no-lockfile': { type: 'boolean', default: false, description: 'Disable lockfile generation and verification.' },
'frozen-lockfile': { type: 'boolean', default: false, description: 'Ensure lockfile exists and remains unchanged; fail otherwise.' },
'omit-syntax-directive': { type: 'boolean', default: false, hidden: true, description: 'Omit Dockerfile syntax directives' },
'include-configuration': { type: 'boolean', default: false, description: 'Include configuration in result.' },
'include-merged-configuration': { type: 'boolean', default: false, description: 'Include merged configuration in result.' },
Expand All @@ -161,6 +163,15 @@ function provisionOptions(y: Argv) {
if (remoteEnvs?.some(remoteEnv => !/.+=.*/.test(remoteEnv))) {
throw new Error('Unmatched argument format: remote-env must match <name>=<value>');
}
if (argv['no-lockfile'] && argv['frozen-lockfile']) {
throw new Error('--no-lockfile and --frozen-lockfile are mutually exclusive.');
}
if (argv['no-lockfile'] && argv['experimental-frozen-lockfile']) {
throw new Error('--no-lockfile and --experimental-frozen-lockfile are mutually exclusive.');
}
if (argv['no-lockfile'] && argv['experimental-lockfile']) {
throw new Error('--no-lockfile and --experimental-lockfile are mutually exclusive.');
}
return true;
});
}
Expand Down Expand Up @@ -213,11 +224,21 @@ async function provision({
'secrets-file': secretsFile,
'experimental-lockfile': experimentalLockfile,
'experimental-frozen-lockfile': experimentalFrozenLockfile,
'no-lockfile': noLockfile,
'frozen-lockfile': frozenLockfile,
'omit-syntax-directive': omitSyntaxDirective,
'include-configuration': includeConfig,
'include-merged-configuration': includeMergedConfig,
}: ProvisionArgs) {

if (experimentalLockfile) {
process.stderr.write('Warning: --experimental-lockfile is deprecated. Lockfiles are now enabled by default.\n');
}
if (experimentalFrozenLockfile) {
process.stderr.write('Warning: --experimental-frozen-lockfile is deprecated. Use --frozen-lockfile instead.\n');
}
const effectiveFrozenLockfile = frozenLockfile || experimentalFrozenLockfile;

const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : undefined;
const addRemoteEnvs = addRemoteEnv ? (Array.isArray(addRemoteEnv) ? addRemoteEnv as string[] : [addRemoteEnv]) : [];
const addCacheFroms = addCacheFrom ? (Array.isArray(addCacheFrom) ? addCacheFrom as string[] : [addCacheFrom]) : [];
Expand Down Expand Up @@ -284,6 +305,8 @@ async function provision({
omitConfigRemotEnvFromMetadata,
experimentalLockfile,
experimentalFrozenLockfile,
noLockfile,
frozenLockfile: effectiveFrozenLockfile,
omitSyntaxDirective,
includeConfig,
includeMergedConfig,
Expand Down Expand Up @@ -527,8 +550,22 @@ function buildOptions(y: Argv) {
'skip-persisting-customizations-from-features': { type: 'boolean', default: false, hidden: true, description: 'Do not save customizations from referenced Features as image metadata' },
'experimental-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Write lockfile' },
'experimental-frozen-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Ensure lockfile remains unchanged' },
'no-lockfile': { type: 'boolean', default: false, description: 'Disable lockfile generation and verification.' },
'frozen-lockfile': { type: 'boolean', default: false, description: 'Ensure lockfile exists and remains unchanged; fail otherwise.' },
'omit-syntax-directive': { type: 'boolean', default: false, hidden: true, description: 'Omit Dockerfile syntax directives' },
});
})
.check(argv => {
if (argv['no-lockfile'] && argv['frozen-lockfile']) {
throw new Error('--no-lockfile and --frozen-lockfile are mutually exclusive.');
}
if (argv['no-lockfile'] && argv['experimental-frozen-lockfile']) {
throw new Error('--no-lockfile and --experimental-frozen-lockfile are mutually exclusive.');
}
if (argv['no-lockfile'] && argv['experimental-lockfile']) {
throw new Error('--no-lockfile and --experimental-lockfile are mutually exclusive.');
}
return true;
});
}

type BuildArgs = UnpackArgv<ReturnType<typeof buildOptions>>;
Expand Down Expand Up @@ -569,8 +606,18 @@ async function doBuild({
'skip-persisting-customizations-from-features': skipPersistingCustomizationsFromFeatures,
'experimental-lockfile': experimentalLockfile,
'experimental-frozen-lockfile': experimentalFrozenLockfile,
'no-lockfile': noLockfile,
'frozen-lockfile': frozenLockfile,
'omit-syntax-directive': omitSyntaxDirective,
}: BuildArgs) {
if (experimentalLockfile) {
process.stderr.write('Warning: --experimental-lockfile is deprecated. Lockfiles are now enabled by default.\n');
}
if (experimentalFrozenLockfile) {
Comment thread
chrmarti marked this conversation as resolved.
Outdated
process.stderr.write('Warning: --experimental-frozen-lockfile is deprecated. Use --frozen-lockfile instead.\n');
}
const effectiveFrozenLockfile = frozenLockfile || experimentalFrozenLockfile;

const disposables: (() => Promise<unknown> | undefined)[] = [];
const dispose = async () => {
await Promise.all(disposables.map(d => d()));
Expand Down Expand Up @@ -619,6 +666,8 @@ async function doBuild({
dotfiles: {},
experimentalLockfile,
experimentalFrozenLockfile,
noLockfile,
frozenLockfile: effectiveFrozenLockfile,
omitSyntaxDirective,
}, disposables);

Expand Down
2 changes: 1 addition & 1 deletion src/spec-node/featureUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ export async function readFeaturesConfig(params: DockerCLIParameters, pkg: Packa
const { cwd, env, platform } = cliHost;
const featuresTmpFolder = await createFeaturesTempFolder({ cliHost, package: pkg });
const cacheFolder = await getCacheFolder(cliHost);
return generateFeaturesConfig({ extensionPath, cacheFolder, cwd, output, env, skipFeatureAutoMapping, platform }, featuresTmpFolder, config, additionalFeatures);
return generateFeaturesConfig({ extensionPath, cacheFolder, cwd, output, env, skipFeatureAutoMapping, platform, noLockfile: true }, featuresTmpFolder, config, additionalFeatures);
}
2 changes: 1 addition & 1 deletion src/spec-node/upgradeCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ async function featuresUpgrade({
const lockfilePath = getLockfilePath(config);
await writeLocalFile(lockfilePath, '');
// Update lockfile
await writeLockfile(params, config, lockfile, true);
await writeLockfile(params, config, lockfile);
} catch (err) {
if (output) {
output.write(err && (err.stack || err.message) || String(err));
Expand Down
2 changes: 2 additions & 0 deletions src/spec-node/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ export interface DockerResolverParameters {
isTTY: boolean;
experimentalLockfile?: boolean;
experimentalFrozenLockfile?: boolean;
noLockfile?: boolean;
frozenLockfile?: boolean;
buildxPlatform: string | undefined;
buildxPush: boolean;
additionalLabels: string[];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/codspace/features/flower:1.0.0": {},
"ghcr.io/codspace/features/color:1.0.4": {}
}
}
Loading